CtrlK
BlogDocsLog inGet started
Tessl Logo

pleaseai/please-plugins

Auto-detect project dependencies and recommend matching Claude Code plugins from the pleaseai marketplace

68

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

check-dependencies.tshooks/

#!/usr/bin/env bun
/**
 * SessionStart + PostToolUse Hook: Plugin Recommender
 *
 * Detects npm packages in the project's package.json and recommends
 * corresponding Claude Code plugins from the pleaseai marketplace.
 *
 * SessionStart (async): Scans package.json at session start
 * PostToolUse (Bash): Fires after bun/npm/pnpm add commands
 *
 * Exit codes:
 *   0 with JSON output = recommendations provided
 *   0 with no output   = no recommendations needed
 */

import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
import process from 'node:process'

export interface PluginMapping {
  packages: string[]
  pluginName: string
}

export interface ToolingMapping {
  indicators: {
    files: string[]
    packageManager: string | null
  }
  pluginName: string
}

export interface DetectedPlugin {
  pluginName: string
  source: string
}

export interface SetupOutput {
  detected: DetectedPlugin[]
  installed: string[]
}

interface HookInput {
  cwd?: string
  hook_event_name?: string
  tool_input?: {
    command?: string
  }
  [key: string]: unknown
}

interface HookOutput {
  hookSpecificOutput: {
    hookEventName: string
    additionalContext: string
  }
  systemMessage: string
}

/**
 * Mapping of npm packages to marketplace plugin names.
 * To add a new mapping, edit plugin-mappings.json.
 */
import mappingsJson from './plugin-mappings.json'
import toolingJson from './tooling-mappings.json'
import { collectAllDependencies as _collectAllDependencies, resolveWorkspacePackages } from './workspace-resolver'
export type { AggregatedDeps } from './workspace-resolver'
export { resolveWorkspacePackages }

/**
 * Collect and merge dependencies from root and all workspace packages.
 * Convenience wrapper that binds the local loadPackageJson.
 */
export function collectAllDependencies(cwd: string) {
  return _collectAllDependencies(cwd, loadPackageJson)
}

export const PLUGIN_MAPPINGS: PluginMapping[] = mappingsJson
export const TOOLING_MAPPINGS: ToolingMapping[] = toolingJson as ToolingMapping[]

/**
 * Detect which plugin mappings match packages in the given package.json content.
 */
export function detectPackages(
  pkg: Record<string, unknown>,
  mappings: PluginMapping[],
): PluginMapping[] {
  const deps = {
    ...(pkg.dependencies as Record<string, string> | undefined),
    ...(pkg.devDependencies as Record<string, string> | undefined),
  }

  const matched: PluginMapping[] = []
  const seen = new Set<string>()

  for (const mapping of mappings) {
    if (seen.has(mapping.pluginName)) continue
    for (const pkgName of mapping.packages) {
      if (pkgName in deps) {
        matched.push(mapping)
        seen.add(mapping.pluginName)
        break
      }
    }
  }

  return matched
}

/**
 * Detect tooling plugins based on lock files and packageManager field.
 */
export function detectTooling(
  cwd: string,
  pkg: Record<string, unknown> | null,
  mappings: ToolingMapping[],
): PluginMapping[] {
  const matched: PluginMapping[] = []
  const seen = new Set<string>()

  for (const mapping of mappings) {
    if (seen.has(mapping.pluginName)) continue

    // Check lock files
    for (const file of mapping.indicators.files) {
      if (existsSync(join(cwd, file))) {
        matched.push({ packages: [], pluginName: mapping.pluginName })
        seen.add(mapping.pluginName)
        break
      }
    }
    if (seen.has(mapping.pluginName)) continue

    // Check packageManager field in package.json
    if (mapping.indicators.packageManager && pkg) {
      const pmField = pkg.packageManager as string | undefined
      if (pmField && pmField.startsWith(mapping.indicators.packageManager)) {
        matched.push({ packages: [], pluginName: mapping.pluginName })
        seen.add(mapping.pluginName)
      }
    }
  }

  return matched
}

/**
 * Filter out plugins that are already installed (present in enabledPlugins).
 * Plugins are considered installed regardless of their enabled/disabled state.
 */
export function filterInstalledPlugins(
  mappings: PluginMapping[],
  enabledPlugins: Record<string, boolean> | undefined,
): PluginMapping[] {
  if (!enabledPlugins) return mappings

  return mappings.filter((mapping) => {
    const key = `${mapping.pluginName}@pleaseai`
    return !(key in enabledPlugins)
  })
}

/**
 * Extract package names from a package install command.
 * Returns empty array for non-install commands or lockfile-only installs.
 */
export function extractPackagesFromCommand(command: string): string[] {
  const trimmed = command.trim()

  // Match: bun add, npm install, npm add, pnpm add, pnpm install
  const installMatch = trimmed.match(
    /^(?:bun\s+add|npm\s+(?:install|add)|pnpm\s+(?:add|install))\s+(.*)/,
  )
  if (!installMatch) return []

  const args = installMatch[1].trim()
  if (!args) return []

  // Split by whitespace, filter out flags (starting with -), and strip version/tag suffixes
  // e.g. pkg@latest → pkg, pkg@^1.0.0 → pkg, but @scope/pkg stays @scope/pkg
  return args
    .split(/\s+/)
    .filter(arg => !arg.startsWith('-') && arg.length > 0)
    .map(arg => arg.replace(/(?<=.)@.*$/, ''))
}

/**
 * Build the hook output JSON with systemMessage and additionalContext.
 */
export function buildOutput(mappings: PluginMapping[], eventName: string): HookOutput {
  const installCommands = mappings
    .map(m => `/plugin install ${m.pluginName}@pleaseai`)
    .join('\n')

  const pluginList = mappings
    .map(m => m.pluginName)
    .join(', ')

  const systemMessage = `📦 Recommended plugins detected! Install them for better assistance:\n\n${installCommands}`

  const additionalContext = `The following Claude Code plugins are available for packages detected in this project: ${pluginList}. These plugins provide specialized skills, context, and tools. Recommend the user install them when relevant to their task.`

  return {
    hookSpecificOutput: {
      hookEventName: eventName,
      additionalContext,
    },
    systemMessage,
  }
}

/**
 * Read and merge enabledPlugins from project-level and user-level settings.
 */
function loadEnabledPlugins(cwd: string): Record<string, boolean> {
  const merged: Record<string, boolean> = {}

  const paths = [
    join(homedir(), '.claude', 'settings.json'),
    join(cwd, '.claude', 'settings.json'),
  ]

  for (const settingsPath of paths) {
    try {
      if (!existsSync(settingsPath)) continue
      const content = readFileSync(settingsPath, 'utf-8')
      const settings = JSON.parse(content)
      if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') {
        Object.assign(merged, settings.enabledPlugins)
      }
    }
    catch {
      // Ignore malformed settings files
    }
  }

  return merged
}

/**
 * Read package.json from the given directory.
 */
function loadPackageJson(cwd: string): Record<string, unknown> | null {
  const pkgPath = join(cwd, 'package.json')
  try {
    if (!existsSync(pkgPath)) return null
    const content = readFileSync(pkgPath, 'utf-8')
    return JSON.parse(content)
  }
  catch {
    return null
  }
}

/**
 * Resolve the source identifier for a detected package plugin.
 * Returns the first matching package name found in dependencies.
 * When depSources is provided, prefixes workspace path for workspace-origin deps.
 */
function resolvePackageSource(
  pluginName: string,
  deps: Record<string, string>,
  mappings: PluginMapping[],
  depSources?: Record<string, string>,
): string {
  const mapping = mappings.find(m => m.pluginName === pluginName)
  const matchedPkg = mapping?.packages.find(p => p in deps)
  if (!matchedPkg) return pluginName

  // If the matched package came from a workspace, prefix with the workspace path
  if (depSources && matchedPkg in depSources) {
    return `${depSources[matchedPkg]}: ${matchedPkg}`
  }

  return matchedPkg
}

/**
 * Resolve the source identifier for a detected tooling plugin.
 * Returns the matched lock file name or packageManager field value.
 */
function resolveToolingSource(
  pluginName: string,
  cwd: string,
  pkg: Record<string, unknown> | null,
  mappings: ToolingMapping[],
): string {
  const mapping = mappings.find(m => m.pluginName === pluginName)
  if (!mapping) return pluginName

  for (const file of mapping.indicators.files) {
    if (existsSync(join(cwd, file))) return file
  }

  if (mapping.indicators.packageManager && pkg) {
    const pmField = pkg.packageManager as string | undefined
    if (pmField && pmField.startsWith(mapping.indicators.packageManager)) {
      return `packageManager:${pmField}`
    }
  }

  return pluginName
}

/**
 * Scan project for setup command. Returns detected plugins with source info,
 * separated into not-yet-installed and already-installed lists.
 * Scans root and all workspace packages.
 */
export function scanForSetup(cwd: string): SetupOutput {
  const pkg = loadPackageJson(cwd)
  const enabledPlugins = loadEnabledPlugins(cwd)

  // Collect deps from root + workspace packages
  const { deps: allDeps, sources: depSources } = collectAllDependencies(cwd)

  // Detect packages using aggregated deps
  const aggregatedPkg = { dependencies: allDeps } as Record<string, unknown>
  const pkgMatches = Object.keys(allDeps).length > 0 ? detectPackages(aggregatedPkg, PLUGIN_MAPPINGS) : []
  const toolingMatches = detectTooling(cwd, pkg, TOOLING_MAPPINGS)

  // Merge, deduplicating by pluginName
  const seen = new Set(pkgMatches.map(m => m.pluginName))
  const allMatches = [...pkgMatches, ...toolingMatches.filter(m => !seen.has(m.pluginName))]

  // Annotate each match with a source identifier and split into installed vs not-installed
  const installed: string[] = []
  const detected: DetectedPlugin[] = []

  for (const match of allMatches) {
    const key = `${match.pluginName}@pleaseai`
    const isTooling = toolingMatches.some(m => m.pluginName === match.pluginName) && !pkgMatches.some(m => m.pluginName === match.pluginName)
    const source = isTooling
      ? resolveToolingSource(match.pluginName, cwd, pkg, TOOLING_MAPPINGS)
      : resolvePackageSource(match.pluginName, allDeps, PLUGIN_MAPPINGS, depSources)

    if (key in enabledPlugins) {
      installed.push(match.pluginName)
    }
    else {
      detected.push({ pluginName: match.pluginName, source })
    }
  }

  return { detected, installed }
}

/**
 * Detect plugin mappings for a given hook event.
 * Returns null if no relevant packages are detected.
 */
function detectForEvent(hookInput: HookInput): PluginMapping[] | null {
  const cwd = hookInput.cwd || process.cwd()
  const eventName = hookInput.hook_event_name || 'SessionStart'

  let detected: PluginMapping[]

  if (eventName === 'PostToolUse') {
    const command = hookInput.tool_input?.command
    if (!command) return null

    const installedPackages = extractPackagesFromCommand(command)
    if (installedPackages.length === 0) return null

    detected = PLUGIN_MAPPINGS.filter(mapping =>
      mapping.packages.some(pkg => installedPackages.includes(pkg)),
    )
  }
  else {
    const pkg = loadPackageJson(cwd)
    const tooling = detectTooling(cwd, pkg, TOOLING_MAPPINGS)

    // Collect deps from root + workspace packages
    const { deps: allDeps } = collectAllDependencies(cwd)

    if (Object.keys(allDeps).length === 0) {
      detected = tooling
    }
    else {
      const aggregatedPkg = { dependencies: allDeps } as Record<string, unknown>
      const pkgDetected = detectPackages(aggregatedPkg, PLUGIN_MAPPINGS)
      // Merge, deduplicating by pluginName
      const seen = new Set(pkgDetected.map(m => m.pluginName))
      detected = [...pkgDetected, ...tooling.filter(m => !seen.has(m.pluginName))]
    }
  }

  if (detected.length === 0) return null

  const enabledPlugins = loadEnabledPlugins(cwd)
  const recommendations = filterInstalledPlugins(detected, enabledPlugins)

  return recommendations.length > 0 ? recommendations : null
}

async function main(): Promise<void> {
  // --setup mode: output structured JSON for the setup command
  if (process.argv.includes('--setup')) {
    try {
      const cwd = process.cwd()
      const result = scanForSetup(cwd)
      process.stdout.write(`${JSON.stringify(result)}\n`)
      process.exit(0)
    }
    catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error)
      process.stderr.write(`[please-plugins] Setup error: ${errorMessage}\n`)
      process.exit(1)
    }
    return
  }

  // Hook mode: read from stdin
  try {
    const input = await Bun.stdin.text()
    if (!input.trim()) {
      process.exit(0)
    }

    let hookInput: HookInput
    try {
      hookInput = JSON.parse(input)
    }
    catch {
      process.exit(0)
    }

    const eventName = hookInput.hook_event_name || 'SessionStart'
    const recommendations = detectForEvent(hookInput)

    if (!recommendations) {
      process.exit(0)
    }

    const output = buildOutput(recommendations, eventName)
    process.stdout.write(`${JSON.stringify(output)}\n`)
    process.exit(0)
  }
  catch (error) {
    const errorMessage = error instanceof Error ? error.message : String(error)
    process.stderr.write(`[please-plugins] Hook error: ${errorMessage}\n`)
    process.exit(0)
  }
}

if (import.meta.main) {
  await main()
}

README.md

tile.json