Files
gh-jbabin91-super-claude-pl…/hooks/skill-activation-prompt.ts
2025-11-29 18:50:12 +08:00

519 lines
13 KiB
TypeScript
Executable File

#!/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<HookInput> {
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<string, SkillConfig> {
const merged = new Map<string, SkillConfig>();
// 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<string, SkillConfig>,
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<Priority, number> = {
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<Priority, number> = {
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<Priority, MatchedSkill[]> = {
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<void> {
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();