#!/usr/bin/env bun /** * Skill Auto-Activation Hook for Claude Code * * This UserPromptSubmit hook analyzes user prompts and suggests relevant skills * before Claude responds. It discovers skill-rules.json files across installed * plugins, merges them with project overrides, and matches against keywords * and intent patterns. * * @see {@link https://github.com/jbabin91/super-claude} for documentation * * Runtime: Bun (native TypeScript support) * Execution: Triggered on every user prompt submission * Performance Target: <50ms for typical projects (<10 plugins) */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import type { HookInput, MatchedSkill, PluginSkillRules, Priority, ProjectSkillRules, SkillConfig, } from '../types/skill-rules.d.ts'; /** * Check if Bun runtime is available. * This function is mainly for documentation - if we're executing, Bun is available. */ function checkBunRuntime(): void { // If this script is running, Bun is available (shebang ensures it) // This check is here for clarity and future enhancement if (typeof Bun === 'undefined') { console.error('[WARNING] Bun required for skill activation'); console.error('Install: https://bun.sh'); process.exit(1); } } /** * Parse hook input from stdin. * * Claude Code passes hook context as JSON via stdin. * * @returns Parsed HookInput object * @throws Error if stdin is empty or invalid JSON */ async function parseStdin(): Promise { const stdin = await Bun.stdin.text(); if (!stdin || stdin.trim() === '') { throw new Error('No input received from stdin'); } try { const input = JSON.parse(stdin) as HookInput; // Validate required fields if (!input.prompt || typeof input.prompt !== 'string') { throw new Error('Invalid input: missing or invalid prompt field'); } if (!input.cwd || typeof input.cwd !== 'string') { throw new Error('Invalid input: missing or invalid cwd field'); } return input; } catch (error) { if (error instanceof SyntaxError) { throw new Error('Invalid JSON from stdin: ' + error.message); } throw error; } } /** * Discover all skill-rules.json files from installed plugins. * * Scans ".claude/skills/star/skill-rules.json" for plugin-level rules. * Handles missing files and invalid JSON gracefully. * * @param cwd Current working directory * @returns Array of validated PluginSkillRules */ function discoverPluginRules(cwd: string): PluginSkillRules[] { const skillsDir = path.resolve(cwd, '.claude/skills'); const pluginRules: PluginSkillRules[] = []; if (!existsSync(skillsDir)) { return pluginRules; } try { const entries = readdirSync(skillsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const rulesPath = path.join(skillsDir, entry.name, 'skill-rules.json'); if (!existsSync(rulesPath)) continue; try { const content = readFileSync(rulesPath, 'utf8'); const rules = JSON.parse(content) as PluginSkillRules; // Validate required fields if (!rules.plugin?.name || !rules.plugin?.namespace || !rules.skills) { console.warn( '[WARNING] Invalid skill-rules.json in ' + entry.name + ': missing required fields', ); continue; } pluginRules.push(rules); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.warn( '[WARNING] Failed to load skill-rules.json from ' + entry.name + ': ' + msg, ); } } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.warn('[WARNING] Failed to read skills directory: ' + msg); } return pluginRules; } /** * Load project-level overrides from .claude/skills/skill-rules.json * * @param cwd Current working directory * @returns ProjectSkillRules or null if not found/invalid */ function loadProjectOverrides(cwd: string): ProjectSkillRules | null { const overridesPath = path.resolve(cwd, '.claude/skills/skill-rules.json'); if (!existsSync(overridesPath)) { return null; } try { const content = readFileSync(overridesPath, 'utf8'); const overrides = JSON.parse(content) as ProjectSkillRules; // Validate schema if (!overrides.version || typeof overrides.version !== 'string') { console.warn( '[WARNING] Invalid project overrides: missing version field', ); return null; } // Ensure required fields exist (with defaults) return { version: overrides.version, overrides: overrides.overrides || {}, disabled: overrides.disabled || [], global: overrides.global, }; } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.warn('[WARNING] Failed to load project overrides: ' + msg); return null; } } /** * Merge plugin rules with project overrides. * * Precedence: Project overrides > Plugin defaults * * Strategy (MVP): * - Shallow merge for skill configs * - Apply disabled list * - Namespace all skill keys as "namespace/skill-name" * * @param pluginRules Array of plugin-level rules * @param projectOverrides Project-level overrides (optional) * @returns Map of skill ID to merged SkillConfig */ function mergeRules( pluginRules: PluginSkillRules[], projectOverrides: ProjectSkillRules | null, ): Map { const merged = new Map(); // 1. Load all plugin rules with namespaced keys for (const plugin of pluginRules) { const namespace = plugin.plugin.namespace; for (const [skillName, config] of Object.entries(plugin.skills)) { const skillId = namespace + '/' + skillName; merged.set(skillId, config); } } // 2. Apply project overrides (shallow merge) if (projectOverrides) { for (const [skillId, override] of Object.entries( projectOverrides.overrides, )) { if (!merged.has(skillId)) { console.warn( '[WARNING] Override for unknown skill: ' + skillId + ' (skill not found in plugins)', ); continue; } // Shallow merge: spread operator replaces entire nested objects const baseConfig = merged.get(skillId)!; merged.set(skillId, { ...baseConfig, ...override }); } // 3. Remove disabled skills for (const skillId of projectOverrides.disabled) { merged.delete(skillId); } } return merged; } /** * Match prompt against skill keywords (case-insensitive literal). * * @param prompt User's prompt text * @param keywords Array of keywords to match * @returns Matched keyword or null */ function matchKeywords( prompt: string, keywords: string[] | undefined, ): string | null { if (!keywords || keywords.length === 0) return null; const normalizedPrompt = prompt.toLowerCase(); for (const keyword of keywords) { if (normalizedPrompt.includes(keyword.toLowerCase())) { return keyword; } } return null; } /** * Match prompt against intent patterns (regex with case-insensitive flag). * * @param prompt User's prompt text * @param patterns Array of regex patterns * @returns Matched pattern or null */ function matchIntentPatterns( prompt: string, patterns: string[] | undefined, ): string | null { if (!patterns || patterns.length === 0) return null; for (const pattern of patterns) { try { const regex = new RegExp(pattern, 'i'); if (regex.test(prompt)) { return pattern; } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.warn( '[WARNING] Invalid regex pattern: ' + pattern + ' (' + msg + ')', ); } } return null; } /** * Find all skills that match the user's prompt. * * Uses both keyword and intent pattern matching. * Filters by priority threshold and disabled list. * * @param prompt User's prompt text * @param skills Map of skill ID to config * @param globalConfig Global configuration (optional) * @returns Array of matched skills */ function matchSkills( prompt: string, skills: Map, globalConfig: ProjectSkillRules['global'], ): MatchedSkill[] { const matches: MatchedSkill[] = []; for (const [skillId, config] of skills.entries()) { const [namespace, name] = skillId.split('/'); // Try keyword matching const keywordMatch = matchKeywords(prompt, config.promptTriggers.keywords); if (keywordMatch) { matches.push({ id: skillId, name, namespace, config, matchType: 'keyword', matchedBy: keywordMatch, }); continue; } // Try intent pattern matching const patternMatch = matchIntentPatterns( prompt, config.promptTriggers.intentPatterns, ); if (patternMatch) { matches.push({ id: skillId, name, namespace, config, matchType: 'intent', matchedBy: patternMatch, }); } } // Apply priority threshold filter let filtered = matches; if (globalConfig?.priorityThreshold) { const priorityOrder: Record = { critical: 0, high: 1, medium: 2, low: 3, }; const threshold = priorityOrder[globalConfig.priorityThreshold]; filtered = matches.filter( (match) => priorityOrder[match.config.priority] <= threshold, ); } // Sort by priority (critical > high > medium > low) const priorityOrder: Record = { critical: 0, high: 1, medium: 2, low: 3, }; filtered.sort( (a, b) => priorityOrder[a.config.priority] - priorityOrder[b.config.priority], ); // Apply maxSkillsPerPrompt limit if (globalConfig?.maxSkillsPerPrompt) { filtered = filtered.slice(0, globalConfig.maxSkillsPerPrompt); } return filtered; } /** * Format matched skills for output. * * Groups skills by priority and formats with box drawing and emojis. * * @param matches Array of matched skills * @returns Formatted string for stdout */ function formatOutput(matches: MatchedSkill[]): string { if (matches.length === 0) { return ''; // No output when no matches } // Group by priority const byPriority: Record = { critical: [], high: [], medium: [], low: [], }; for (const match of matches) { byPriority[match.config.priority].push(match); } // Build sections conditionally const criticalSection = byPriority.critical.length > 0 ? [ '[CRITICAL] REQUIRED SKILLS:', ...byPriority.critical.map((match) => ' -> ' + match.name), '', ] : []; const highSection = byPriority.high.length > 0 ? [ '[RECOMMENDED] SKILLS:', ...byPriority.high.map((match) => ' -> ' + match.name), '', ] : []; const mediumSection = byPriority.medium.length > 0 ? [ '[OPTIONAL] SKILLS:', ...byPriority.medium.map((match) => ' -> ' + match.name), '', ] : []; const lowSection = byPriority.low.length > 0 ? [ '[SUGGESTED] SKILLS:', ...byPriority.low.map((match) => ' -> ' + match.name), '', ] : []; const lines = [ '='.repeat(60), 'SKILL ACTIVATION CHECK', '='.repeat(60), '', ...criticalSection, ...highSection, ...mediumSection, ...lowSection, 'ACTION: Use Skill tool BEFORE responding', '='.repeat(60), ]; return lines.join('\n'); } /** * Main hook execution. * * Workflow: * 1. Check Bun runtime * 2. Parse stdin * 3. Discover plugin rules * 4. Load project overrides * 5. Merge rules * 6. Match prompt * 7. Format output * 8. Exit cleanly */ async function main(): Promise { const startTime = Date.now(); try { // 1. Check runtime checkBunRuntime(); // 2. Parse input const input = await parseStdin(); // 3. Discover plugin rules const pluginRules = discoverPluginRules(input.cwd); // 4. Load project overrides const projectOverrides = loadProjectOverrides(input.cwd); // 5. Merge rules const mergedSkills = mergeRules(pluginRules, projectOverrides); // 6. Match skills const matches = matchSkills( input.prompt, mergedSkills, projectOverrides?.global, ); // 7. Format and output const output = formatOutput(matches); if (output) { console.log(output); } // Performance monitoring const duration = Date.now() - startTime; if (duration > 50) { console.warn('[WARNING] Slow hook execution: ' + duration + 'ms'); } process.exit(0); } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error'; console.error('[ERROR] Hook error: ' + msg); process.exit(1); } } // Execute await main();