commit 67f4104a81e224d603ce2d1b4ffd76a3ecb75f89 Author: Zhongwei Li Date: Sat Nov 29 18:01:40 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..c3f9733 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "skill-activation", + "description": "Centralized skill activation system that automatically suggests relevant skills from installed plugins based on user prompts and file context", + "version": "1.0.0", + "author": { + "name": "boneskull" + }, + "skills": [ + "./skills" + ], + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3787c79 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# skill-activation + +Centralized skill activation system that automatically suggests relevant skills from installed plugins based on user prompts and file context diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..1d3611f --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/skill-activation-prompt.ts" + } + ] + } + ] + } +} diff --git a/hooks/package.json b/hooks/package.json new file mode 100644 index 0000000..c90a43b --- /dev/null +++ b/hooks/package.json @@ -0,0 +1,17 @@ +{ + "name": "@skill-activation/hooks", + "version": "1.0.0", + "type": "module", + "description": "Hook scripts for skill-activation plugin", + "private": true, + "scripts": { + "test": "echo '{ \"prompt\": \"help me write a test\", \"cwd\": \".\", \"session_id\": \"test\", \"transcript_path\": \"/tmp/test\", \"permission_mode\": \"auto\" }' | npx tsx scripts/skill-activation-prompt.ts" + }, + "dependencies": { + "tsx": "^4.19.2" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.7.2" + } +} diff --git a/hooks/scripts/skill-activation-prompt.ts b/hooks/scripts/skill-activation-prompt.ts new file mode 100644 index 0000000..f9c24e6 --- /dev/null +++ b/hooks/scripts/skill-activation-prompt.ts @@ -0,0 +1,387 @@ +#!/usr/bin/env node + +import { accessSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +interface HookInput { + session_id: string; + transcript_path: string; + cwd: string; + permission_mode: string; + prompt: string; +} + +interface PromptTriggers { + keywords?: string[]; + intentPatterns?: string[]; +} + +interface FileTriggers { + pathPatterns?: string[]; + pathExclusions?: string[]; + contentPatterns?: string[]; +} + +interface SkipConditions { + sessionSkillUsed?: boolean; + fileMarkers?: string[]; + envOverride?: string; +} + +interface SkillRule { + type: 'guardrail' | 'domain'; + enforcement: 'block' | 'suggest' | 'warn'; + priority: 'critical' | 'high' | 'medium' | 'low'; + description?: string; + promptTriggers?: PromptTriggers; + fileTriggers?: FileTriggers; + blockMessage?: string; + skipConditions?: SkipConditions; +} + +interface SkillRules { + version: string; + description?: string; + skills: Record; + notes?: Record; +} + +interface InstalledPlugin { + version: string; + installedAt: string; + lastUpdated: string; + installPath: string; + gitCommitSha?: string; + isLocal?: boolean; +} + +interface InstalledPlugins { + version: number; + plugins: Record; +} + +interface MatchedSkill { + name: string; + displayName: string; + matchType: 'keyword' | 'intent'; + config: SkillRule; + skillPath: string | null; +} + +/** + * Parse a skill reference in format "plugin@marketplace:skill-name" Returns + * null if the reference is in legacy format (no plugin qualifier) + */ +function parseSkillRef( + skillRef: string, +): { pluginId: string; skillName: string } | null { + const match = skillRef.match(/^(.+?)@(.+?):(.+)$/); + if (!match) { + return null; // Legacy format (local project skill) + } + const [, pluginName, marketplace, skillName] = match; + return { + pluginId: `${pluginName}@${marketplace}`, + skillName: skillName!, + }; +} + +/** + * Resolve a skill reference to an absolute file path Returns null if: + * + * - Plugin is not installed + * - Skill file doesn't exist in the plugin + * + * For legacy format (no plugin qualifier), looks in project .claude/skills/ + */ +function resolveSkillPath( + skillRef: string, + installedPlugins: InstalledPlugins, +): string | null { + const parsed = parseSkillRef(skillRef); + + if (!parsed) { + // Legacy format: local project skill + const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const skillPath = join( + projectDir, + '.claude', + 'skills', + skillRef, + 'SKILL.md', + ); + + try { + accessSync(skillPath); + return skillPath; + } catch { + return null; + } + } + + const { pluginId, skillName } = parsed; + + // Check if plugin is installed + const plugin = installedPlugins.plugins[pluginId]; + if (!plugin) { + return null; // Plugin not installed - gracefully skip + } + + // Construct skill path + const skillPath = join(plugin.installPath, 'skills', skillName, 'SKILL.md'); + + // Verify skill exists + try { + accessSync(skillPath); + return skillPath; + } catch { + return null; // Skill doesn't exist - gracefully skip + } +} + +/** + * Load installed plugins metadata from Claude's global config + */ +function loadInstalledPlugins(): InstalledPlugins { + const pluginsPath = join( + homedir(), + '.claude', + 'plugins', + 'installed_plugins.json', + ); + + try { + const content = readFileSync(pluginsPath, 'utf-8'); + return JSON.parse(content); + } catch (err) { + // If we can't read installed plugins, return empty structure + console.error('Warning: Could not load installed plugins:', err); + return { version: 1, plugins: {} }; + } +} + +/** + * Create a display name for the skill For plugin skills: + * "plugin-name:skill-name" For local skills: just "skill-name" + */ +const getDisplayName = (skillRef: string): string => { + const parsed = parseSkillRef(skillRef); + if (!parsed) { + return skillRef; // Legacy format + } + + const { pluginId, skillName } = parsed; + // Extract just the plugin name (before @) + const pluginName = pluginId.split('@')[0]; + return `${pluginName}:${skillName}`; +}; + +const loadSkillRules = (rulesPath: string): SkillRules | undefined => { + try { + accessSync(rulesPath); + const pluginRules: SkillRules = JSON.parse( + readFileSync(rulesPath, 'utf-8'), + ); + + return pluginRules; + } catch {} +}; + +/** + * Load and merge skill rules from all sources in priority order: + * + * 1. Plugin-defined rules (lowest priority - defaults) + * 2. Global user rules ~/.claude/skill-rules.json (middle priority) + * 3. Project rules .claude/skill-rules.json (highest priority) + * + * Higher priority rules override lower priority rules + */ +const loadAllSkillRules = ( + installedPlugins: InstalledPlugins, + projectDir: string, +): SkillRules => { + const mergedRules: SkillRules = { + version: '1.0', + skills: {}, + }; + + // 1. Load plugin-defined rules (lowest priority - defaults) + let pluginRulesLoaded = 0; + for (const plugin of Object.values(installedPlugins.plugins)) { + const rulesPath = join(plugin.installPath, 'skills', 'skill-rules.json'); + + const pluginRules = loadSkillRules(rulesPath); + if (pluginRules) { + Object.assign(mergedRules.skills, pluginRules.skills); + pluginRulesLoaded++; + } + } + + // 2. Load global user rules (middle priority - overrides plugin defaults) + let globalRulesLoaded = false; + const globalRulesPath = join(homedir(), '.claude', 'skill-rules.json'); + const globalRules = loadSkillRules(globalRulesPath); + if (globalRules) { + Object.assign(mergedRules.skills, globalRules.skills); + globalRulesLoaded = true; + } + + // 3. Load project rules (highest priority - overrides everything) + const projectRulesPath = join(projectDir, '.claude', 'skill-rules.json'); + let projectRulesLoaded = false; + const projectRules = loadSkillRules(projectRulesPath); + if (projectRules) { + Object.assign(mergedRules.skills, projectRules.skills); + projectRulesLoaded = true; + } + + // Debug info (only if no rules found at all) + if (pluginRulesLoaded === 0 && !globalRulesLoaded && !projectRulesLoaded) { + console.error( + 'Warning: No skill rules found in plugins, global config, or project config', + ); + } + + return mergedRules; +}; + +const main = async () => { + // Read input from stdin + const input = readFileSync(0, 'utf-8'); + const data: HookInput = JSON.parse(input); + const prompt = data.prompt.toLowerCase(); + + // Load installed plugins + const installedPlugins = loadInstalledPlugins(); + + // Get project directory + const projectDir = process.env.CLAUDE_PROJECT_DIR || data.cwd; + + // Load and merge all skill rules + const rules = loadAllSkillRules(installedPlugins, projectDir); + + const matchedSkills: MatchedSkill[] = []; + + // Check each skill for matches + for (const [skillRef, config] of Object.entries(rules.skills)) { + const triggers = config.promptTriggers; + if (!triggers) { + continue; + } + + let matched = false; + let matchType: 'keyword' | 'intent' = 'keyword'; + + // Keyword matching + if (triggers.keywords) { + const keywordMatch = triggers.keywords.some((kw) => + prompt.includes(kw.toLowerCase()), + ); + if (keywordMatch) { + matched = true; + matchType = 'keyword'; + } + } + + // Intent pattern matching (only if not already matched by keyword) + if (!matched && triggers.intentPatterns) { + const intentMatch = triggers.intentPatterns.some((pattern) => { + const regex = new RegExp(pattern, 'i'); + return regex.test(prompt); + }); + if (intentMatch) { + matched = true; + matchType = 'intent'; + } + } + + if (matched) { + // Resolve skill path + const skillPath = resolveSkillPath(skillRef, installedPlugins); + + // Only skip if skill doesn't exist + // Still show in output but mark as unavailable + matchedSkills.push({ + name: skillRef, + displayName: getDisplayName(skillRef), + matchType, + config, + skillPath, + }); + } + } + + // Generate output if matches found + if (matchedSkills.length > 0) { + let output = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'; + output += '🎯 SKILL ACTIVATION CHECK\n'; + output += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'; + + // Group by priority + const critical = matchedSkills.filter( + (s) => s.config.priority === 'critical', + ); + const high = matchedSkills.filter((s) => s.config.priority === 'high'); + const medium = matchedSkills.filter((s) => s.config.priority === 'medium'); + const low = matchedSkills.filter((s) => s.config.priority === 'low'); + + // Helper to format skill with availability status + const formatSkill = (s: MatchedSkill): string => { + if (s.skillPath === null) { + return ` → ${s.displayName} ⚠️ (plugin not installed)`; + } + return ` → ${s.displayName}`; + }; + + if (critical.length > 0) { + output += '⚠️ CRITICAL SKILLS (REQUIRED):\n'; + critical.forEach((s) => (output += formatSkill(s) + '\n')); + output += '\n'; + } + + if (high.length > 0) { + output += '📚 RECOMMENDED SKILLS:\n'; + high.forEach((s) => (output += formatSkill(s) + '\n')); + output += '\n'; + } + + if (medium.length > 0) { + output += '💡 SUGGESTED SKILLS:\n'; + medium.forEach((s) => (output += formatSkill(s) + '\n')); + output += '\n'; + } + + if (low.length > 0) { + output += '📌 OPTIONAL SKILLS:\n'; + low.forEach((s) => (output += formatSkill(s) + '\n')); + output += '\n'; + } + + // Check if any skills are unavailable + const unavailable = matchedSkills.filter((s) => s.skillPath === null); + if (unavailable.length > 0) { + output += '💡 TIP: Some skills require installing additional plugins\n'; + } + + output += 'ACTION: Use Skill tool BEFORE responding\n'; + output += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'; + + // Output JSON for UserPromptSubmit hook + const hookOutput = { + systemMessage: output, // Displayed directly to the user + hookSpecificOutput: { + hookEventName: 'UserPromptSubmit', + additionalContext: output, // Added to Claude's context + }, + }; + + console.log(JSON.stringify(hookOutput, null, 2)); + } + // No output when no skills match (allow prompt to proceed normally) +}; + +main().catch((err) => { + console.error('Uncaught error:', err); + process.exitCode = 1; +}); diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..648d023 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,57 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:boneskull/claude-plugins:plugins/skill-activation", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "63c51e5b56db9c94f5323b0793bbee48e5489d0e", + "treeHash": "2fb12c47453ca0cb09c8c8353e09abc519ccba6a840c6d1222241a88a0edc076", + "generatedAt": "2025-11-28T10:14:20.025401Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "skill-activation", + "description": "Centralized skill activation system that automatically suggests relevant skills from installed plugins based on user prompts and file context", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "f84c2e959e9a1b21f1c111d73863dfe9345c698612dccbece94732f92e07f4bf" + }, + { + "path": "hooks/package.json", + "sha256": "944848092fda4a8007aaf0a83e240a8c523679a449ce6583f5b5d88821b9ee34" + }, + { + "path": "hooks/hooks.json", + "sha256": "d95d8e79fc061208085fb026fa208dc5643d516969a327ede7bb21164f7bda71" + }, + { + "path": "hooks/scripts/skill-activation-prompt.ts", + "sha256": "d66d6d9d14bb2575c7db1769ba4b391a42f024732e5b26c8a76814b5a5e9049a" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "b9701a162fc06b81d78f57c7323a3cdf170263603f5555dea982f92097679da0" + }, + { + "path": "skills/skill-rules.example.json", + "sha256": "337d606a42757f48a40c84b3167f32ffc9f7f3378bd4d74be713e6ae50d27c87" + } + ], + "dirSha256": "2fb12c47453ca0cb09c8c8353e09abc519ccba6a840c6d1222241a88a0edc076" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/skill-rules.example.json b/skills/skill-rules.example.json new file mode 100644 index 0000000..8a9d5cd --- /dev/null +++ b/skills/skill-rules.example.json @@ -0,0 +1,120 @@ +{ + "description": "Example skill rules configuration for global (~/.claude/skill-rules.json) or project (.claude/skill-rules.json) use", + "notes": { + "usage": "Copy this to ~/.claude/skill-rules.json for global rules or .claude/skill-rules.json for project-specific rules", + "precedence": "PROJECT > GLOBAL > PLUGIN (project rules override global rules, which override plugin defaults)", + "partial_overrides": "You only need to specify properties you want to change - they merge with plugin defaults", + "third_party": "Use this to configure skills from marketplace plugins you don't control", + "extensibility": "Add any installed plugin's skills using the format: 'plugin@marketplace:skill-name'" + }, + "skills": { + "_comment_third_party_plugins": "=== Rules for Third-Party Marketplace Plugins ===", + + "superpowers@superpowers-marketplace:test-driven-development": { + "type": "guardrail", + "enforcement": "suggest", + "priority": "high", + "description": "TDD workflow: write test first, watch it fail, then implement", + "promptTriggers": { + "keywords": [ + "write test", + "add test", + "test for", + "create test", + "test coverage", + "unit test", + "integration test", + "TDD" + ], + "intentPatterns": [ + "(create|write|add|implement).*test", + "test.*(feature|function|method|component)", + "(how to|how do I).*test" + ] + } + }, + + "superpowers@superpowers-marketplace:systematic-debugging": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "description": "Four-phase debugging: root cause investigation, pattern analysis, hypothesis testing, implementation", + "promptTriggers": { + "keywords": [ + "bug", + "debug", + "error", + "not working", + "broken", + "failing", + "issue", + "problem" + ], + "intentPatterns": [ + "(fix|debug|solve|investigate).*(bug|error|issue|problem)", + "(why|what).*(not working|broken|failing)", + "(figure out|find out).*why" + ] + } + }, + + "superpowers@superpowers-marketplace:brainstorming": { + "type": "domain", + "enforcement": "suggest", + "priority": "medium", + "description": "Interactive design refinement using Socratic method before implementation", + "promptTriggers": { + "keywords": [ + "design", + "architecture", + "approach", + "plan", + "should I", + "what's the best way", + "how should I" + ], + "intentPatterns": [ + "(design|architect|plan).*feature", + "(what|how).*should.*(design|implement|build)", + "(best way|approach).*to" + ] + } + }, + + "elements-of-style@superpowers-marketplace:writing-clearly-and-concisely": { + "type": "domain", + "enforcement": "suggest", + "priority": "medium", + "description": "Apply Elements of Style writing rules to documentation, commit messages, and error messages", + "promptTriggers": { + "keywords": [ + "write documentation", + "README", + "improve writing", + "edit documentation", + "write guide", + "documentation", + "docs" + ], + "intentPatterns": [ + "(write|create|update|improve).*(doc|documentation|readme|guide)", + "(improve|edit|refine).*(writing|prose|text)" + ] + } + }, + + "_comment_override_examples": "=== Examples of Overriding Plugin Defaults ===", + + "tools@boneskull-plugins:git-commit-messages": { + "_comment": "Override to make git commit messages blocking instead of suggesting", + "priority": "critical", + "enforcement": "block" + }, + + "bupkis@boneskull-plugins:bupkis-assertion-patterns": { + "_comment": "Lower priority for projects that don't use bupkis heavily", + "priority": "low" + } + }, + "version": "1.0" +}