Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:01:40 +08:00
commit 67f4104a81
7 changed files with 612 additions and 0 deletions

View File

@@ -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"
]
}

3
README.md Normal file
View File

@@ -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

14
hooks/hooks.json Normal file
View File

@@ -0,0 +1,14 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "npx tsx ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/skill-activation-prompt.ts"
}
]
}
]
}
}

17
hooks/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<string, SkillRule>;
notes?: Record<string, any>;
}
interface InstalledPlugin {
version: string;
installedAt: string;
lastUpdated: string;
installPath: string;
gitCommitSha?: string;
isLocal?: boolean;
}
interface InstalledPlugins {
version: number;
plugins: Record<string, InstalledPlugin>;
}
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;
});

57
plugin.lock.json Normal file
View File

@@ -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": []
}
}

View File

@@ -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"
}