Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:50:12 +08:00
commit d16c5de665
29 changed files with 4788 additions and 0 deletions

236
hooks/branch-name-validator.ts Executable file
View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bun
/**
* Branch Name Validator Hook
*
* Enforces conventional commit prefixes for branch names (GitHub Flow).
* Valid patterns: feat/*, fix/*, chore/*, docs/*, test/*, refactor/*, perf/*
* Allows main/master (protected branches).
*
* Performance target: <50ms (ADR-0010)
*
* @see {@link https://github.com/jbabin91/super-claude} for documentation
*/
import { checkPerformance, formatError, parseStdin } from './utils/index.js';
import {
checkHookEnabled,
getHookConfig,
} from './utils/super-claude-config-loader.js';
/**
* Extract branch name from git checkout command
*
* @param command Git command string
* @returns Branch name or null if not a checkout -b command
*/
function extractBranchName(command: string): string | null {
// Match: git checkout -b <branch-name>
const match = /git\s+checkout\s+-b\s+([^\s]+)/.exec(command);
return match ? match[1] : null;
}
/**
* Validate branch name follows conventional commit pattern
*
* @param branchName Branch name to validate
* @param allowedPrefixes List of valid prefixes
* @param allowedBranches Set of branches allowed without prefix
* @returns true if valid, false otherwise
*/
function isValidBranchName(
branchName: string,
allowedPrefixes: string[],
allowedBranches: Set<string>,
): boolean {
// Allow protected branches
if (allowedBranches.has(branchName)) {
return true;
}
// Check if follows pattern: <type>/<description>
const pattern = new RegExp(`^(${allowedPrefixes.join('|')})/[a-z0-9-]+$`);
return pattern.test(branchName);
}
/**
* Get suggested branch name from invalid name
*
* @param branchName Invalid branch name
* @param allowedPrefixes List of valid prefixes
* @returns Suggested valid branch name
*/
function suggestBranchName(
branchName: string,
allowedPrefixes: string[],
): string {
// Try to extract a valid prefix if present
for (const prefix of allowedPrefixes) {
if (branchName.toLowerCase().includes(prefix)) {
const description = branchName
.toLowerCase()
.replace(new RegExp(`^${prefix}[/-]?`), '')
.replaceAll(/[^a-z0-9-]/g, '-')
.replaceAll(/-+/g, '-')
.replaceAll(/^-|-$/g, '');
return description ? `${prefix}/${description}` : `${prefix}/description`;
}
}
// Default suggestion
return `feat/${branchName.toLowerCase().replaceAll(/[^a-z0-9-]/g, '-')}`;
}
/**
* Format blocking message
*
* @param branchName Invalid branch name
* @param allowedPrefixes List of valid prefixes
* @returns Formatted error message
*/
function formatBlockMessage(
branchName: string,
allowedPrefixes: string[],
): string {
const suggested = suggestBranchName(branchName, allowedPrefixes);
return [
'',
'═'.repeat(70),
'❌ INVALID BRANCH NAME',
'═'.repeat(70),
'',
`Branch name: ${branchName}`,
'',
'GitHub Flow + Conventional Commits requires:',
' <type>/<description>',
'',
'Valid types:',
` ${allowedPrefixes.join(', ')}`,
'',
'Description rules:',
' • Use kebab-case (lowercase with hyphens)',
' • Be descriptive and concise',
' • Use letters, numbers, and hyphens only',
'',
'✅ Valid examples:',
' feat/user-authentication',
' fix/memory-leak-in-parser',
' docs/api-reference',
' test/validate-branch-names',
' refactor/simplify-hooks',
'',
'❌ Invalid examples:',
' feature/auth (use "feat" not "feature")',
' fix_bug (use "/" not "_")',
' MyFeature (no type prefix)',
' feat/Fix Bug (use kebab-case)',
'',
`💡 Suggested: git checkout -b ${suggested}`,
'',
'═'.repeat(70),
'',
].join('\n');
}
/**
* Main hook execution
*/
async function main(): Promise<void> {
const startTime = Date.now();
try {
const input = await parseStdin();
// Check if hook is enabled
checkHookEnabled(input.cwd, 'workflow', 'branchNameValidator');
// Get hook configuration
const config = getHookConfig(input.cwd, 'workflow', 'branchNameValidator');
const allowedPrefixes = (config.allowedPrefixes as string[]) ?? [
'feat',
'fix',
'chore',
'docs',
'test',
'refactor',
'perf',
'build',
'ci',
'revert',
'style',
];
const allowedBranchList = (config.allowedBranches as string[]) ?? [
'main',
'master',
'develop',
];
const allowedBranches = new Set(allowedBranchList);
// Only run for Bash tool
if (input.tool_name !== 'Bash') {
process.exit(0);
}
// Extract command from tool input
const toolInput = input.tool_input!;
const command = toolInput?.command as string | undefined;
if (!command) {
process.exit(0);
}
// Only check git checkout -b commands
if (!command.includes('git checkout -b')) {
process.exit(0);
}
// Extract branch name
const branchName = extractBranchName(command);
if (!branchName) {
process.exit(0);
}
console.error('[DEBUG] branch-name-validator: Checking branch name');
console.error(`[DEBUG] Branch name: ${branchName}`);
console.error(`[DEBUG] Allowed prefixes: ${allowedPrefixes.join(', ')}`);
console.error(
`[DEBUG] Allowed branches: ${[...allowedBranches].join(', ')}`,
);
// Validate branch name
if (!isValidBranchName(branchName, allowedPrefixes, allowedBranches)) {
// Block the operation
console.error('[DEBUG] BLOCKING - invalid branch name');
const output = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: formatBlockMessage(
branchName,
allowedPrefixes,
),
},
};
console.log(JSON.stringify(output));
checkPerformance(startTime, 50, 'branch-name-validator');
process.exit(0);
}
// Allow the operation - valid branch name
console.error('[DEBUG] ALLOWING - valid branch name');
checkPerformance(startTime, 50, 'branch-name-validator');
process.exit(0);
} catch (error) {
console.error(formatError(error, 'branch-name-validator'));
// On hook error, don't block the operation
process.exit(0);
}
}
// Execute
await main();

235
hooks/git-commit-guard.ts Executable file
View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bun
/**
* Git Commit Guard Hook
*
* Prevents direct commits/pushes to protected branches (main).
* Enforces feature branch workflow - commits should happen on feature branches.
* Use bypass environment variable for emergencies: SKIP_COMMIT_GUARD=true
*
* Performance target: <50ms (ADR-0010)
*
* @see {@link https://github.com/jbabin91/super-claude} for documentation
*/
import { execSync } from 'node:child_process';
import { checkPerformance, formatError, parseStdin } from './utils/index.js';
import {
checkHookEnabled,
getHookConfig,
} from './utils/super-claude-config-loader.js';
/**
* Get current git branch
*
* @param cwd Current working directory
* @returns Current branch name or null if not in git repo
*/
function getCurrentBranch(cwd: string): string | null {
try {
const branch = execSync('git branch --show-current', {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return branch || null;
} catch {
return null;
}
}
/**
* Detect if Bash command is a git commit or push
*
* @param command Bash command string
* @returns true if commit/push command
*/
function isGitCommitOrPush(command: string): boolean {
const patterns = [
/\bgit\s+commit\b/i,
/\bgit\s+push\b/i,
/\bgit\s+ci\b/i, // Common commit alias
];
return patterns.some((pattern) => pattern.test(command));
}
/**
* Check if command targets a protected branch
*
* @param command Git command
* @param currentBranch Current branch name
* @param protectedBranches List of protected branches
* @returns true if targeting protected branch
*/
function targetsProtectedBranch(
command: string,
currentBranch: string | null,
protectedBranches: string[],
): boolean {
// Check if push command explicitly targets protected branch
for (const branch of protectedBranches) {
if (
command.includes(`origin ${branch}`) ||
command.includes(`origin/${branch}`)
) {
return true;
}
}
// Check if current branch is protected
if (currentBranch && protectedBranches.includes(currentBranch)) {
return true;
}
return false;
}
/**
* Format blocking message
*
* @param currentBranch Current branch name
* @param command Git command that was blocked
* @param protectedBranches List of protected branches
* @returns Formatted error message
*/
function formatBlockMessage(
currentBranch: string | null,
command: string,
protectedBranches: string[],
): string {
return [
'',
'═'.repeat(70),
'⚠️ DIRECT COMMIT/PUSH TO PROTECTED BRANCH BLOCKED',
'═'.repeat(70),
'',
`Protected branches: ${protectedBranches.join(', ')}`,
`Current branch: ${currentBranch ?? 'unknown'}`,
`Blocked command: ${command}`,
'',
'Feature Branch Workflow:',
'',
' 1. Create a feature branch:',
' git checkout -b feat/your-feature',
'',
' 2. Make commits on your feature branch:',
' git commit -m "feat: add new feature"',
'',
' 3. Push feature branch:',
' git push origin feat/your-feature',
'',
' 4. Create pull request:',
' gh pr create',
'',
' 5. After approval, merge via PR',
'',
'To bypass this guard (emergencies only):',
' SKIP_COMMIT_GUARD=true git commit -m "..."',
' SKIP_COMMIT_GUARD=true git push',
'',
'═'.repeat(70),
'',
].join('\n');
}
/**
* Main hook execution
*/
async function main(): Promise<void> {
const startTime = Date.now();
try {
const input = await parseStdin();
// Check if hook is enabled and get config
checkHookEnabled(input.cwd, 'workflow', 'gitCommitGuard');
// Get hook configuration
const config = getHookConfig(input.cwd, 'workflow', 'gitCommitGuard');
const protectedBranches = (config.protectedBranches as string[]) ?? [
'main',
'master',
];
const bypassEnvVar = (config.bypassEnvVar as string) ?? 'SKIP_COMMIT_GUARD';
// Check for bypass flag
if (process.env[bypassEnvVar] === 'true') {
console.error(`[DEBUG] ${bypassEnvVar}=true - bypassing guard`);
checkPerformance(startTime, 50, 'git-commit-guard');
process.exit(0);
}
// Only run for Bash tool
if (input.tool_name !== 'Bash') {
process.exit(0);
}
// Extract command from tool input
const toolInput = input.tool_input!;
const command = toolInput?.command as string | undefined;
if (!command) {
process.exit(0);
}
// Check for bypass flag in command string
if (command.includes(`${bypassEnvVar}=true`)) {
console.error(
`[DEBUG] ${bypassEnvVar}=true in command - bypassing guard`,
);
checkPerformance(startTime, 50, 'git-commit-guard');
process.exit(0);
}
// Only check git commit/push commands
if (!isGitCommitOrPush(command)) {
process.exit(0);
}
// Get current branch
const currentBranch = getCurrentBranch(input.cwd);
console.error('[DEBUG] git-commit-guard: Git operation detected');
console.error(`[DEBUG] Current branch: ${currentBranch}`);
console.error(`[DEBUG] Command: ${command}`);
console.error(
`[DEBUG] Protected branches: ${protectedBranches.join(', ')}`,
);
// Check if targeting protected branch
if (targetsProtectedBranch(command, currentBranch, protectedBranches)) {
// Block the operation
console.error('[DEBUG] BLOCKING - targets protected branch');
const output = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: formatBlockMessage(
currentBranch,
command,
protectedBranches,
),
},
};
console.log(JSON.stringify(output));
checkPerformance(startTime, 50, 'git-commit-guard');
process.exit(0);
}
// Allow the operation - not targeting protected branch
console.error('[DEBUG] ALLOWING - not targeting protected branch');
checkPerformance(startTime, 50, 'git-commit-guard');
process.exit(0);
} catch (error) {
console.error(formatError(error, 'git-commit-guard'));
// On hook error, don't block the operation
process.exit(0);
}
}
// Execute
await main();

49
hooks/hooks.json Normal file
View File

@@ -0,0 +1,49 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-checklist.ts"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/type-checker.ts"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/git-commit-guard.ts"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/branch-name-validator.ts"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/skill-activation-prompt.ts"
}
]
}
]
},
"description": "Workflow plugin hooks for session checklists, type checking, commit guards, branch name validation, and skill auto-activation"
}

203
hooks/session-checklist.ts Executable file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env bun
/**
* Session Checklist Hook
*
* Displays quick project status at session start:
* - Git status (branch, staged files, recent commits)
* - Active OpenSpec changes
* - Quick command reference
*
* Performance target: <100ms (ADR-0010)
*
* @see {@link https://github.com/jbabin91/super-claude} for documentation
*/
import { execSync } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { checkPerformance, formatError, parseStdin } from './utils/index.js';
/**
* Get git status information
*
* @param cwd Current working directory
* @returns Git status summary or null if not a git repo
*/
function getGitStatus(cwd: string): {
branch: string;
staged: number;
unstaged: number;
untracked: number;
} | null {
try {
// Get branch (also validates git repo)
const branch = execSync('git branch --show-current', {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
// Get status counts
const status = execSync('git status --porcelain', {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
let staged = 0;
let unstaged = 0;
let untracked = 0;
for (const line of status.split('\n')) {
if (!line) continue;
const x = line[0];
const y = line[1];
if (x !== ' ' && x !== '?') staged++;
if (y !== ' ' && y !== '?') unstaged++;
if (x === '?' && y === '?') untracked++;
}
return { branch, staged, unstaged, untracked };
} catch {
return null; // Not a git repo or git error
}
}
/**
* Get recent commits
*
* @param cwd Current working directory
* @param count Number of commits to fetch
* @returns Array of commit summaries
*/
function getRecentCommits(cwd: string, count = 3): string[] {
try {
const log = execSync(
`git log -n ${count} --pretty=format:"%h %s" --no-decorate`,
{ cwd, encoding: 'utf8' },
);
return log.split('\n').filter((line) => line.trim());
} catch {
return [];
}
}
/**
* Get active OpenSpec changes
*
* @param cwd Current working directory
* @returns Array of change names
*/
function getActiveChanges(cwd: string): string[] {
const changesDir = path.join(cwd, 'openspec', 'changes');
if (!existsSync(changesDir)) {
return [];
}
try {
const entries = readdirSync(changesDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && entry.name !== 'archive')
.map((entry) => entry.name);
} catch {
return [];
}
}
/**
* Format checklist output
*
* @param cwd Current working directory
* @returns Formatted checklist string
*/
function formatChecklist(cwd: string): string {
const git = getGitStatus(cwd);
const commits = getRecentCommits(cwd);
const changes = getActiveChanges(cwd);
const lines = ['═'.repeat(70), '📋 SESSION CHECKLIST', '═'.repeat(70), ''];
// Git status
if (git) {
lines.push('Git Status:', ` Branch: ${git.branch}`);
const status = [];
if (git.staged > 0) status.push(`${git.staged} staged`);
if (git.unstaged > 0) status.push(`${git.unstaged} modified`);
if (git.untracked > 0) status.push(`${git.untracked} untracked`);
if (status.length > 0) {
lines.push(` Status: ${status.join(', ')}`);
} else {
lines.push(' Status: Clean working directory');
}
// Recent commits
if (commits.length > 0) {
lines.push('', 'Recent Commits:');
for (const commit of commits) {
lines.push(` ${commit}`);
}
}
} else {
lines.push('Git: Not a git repository');
}
lines.push('');
// OpenSpec changes
if (changes.length > 0) {
lines.push('Active Changes:');
for (const change of changes) {
lines.push(`${change}`);
}
} else {
lines.push('OpenSpec: No active changes');
}
// Quick reference
lines.push(
'',
'─'.repeat(70),
'Quick Commands:',
' openspec list # List active changes',
' openspec show <change> # View change details',
' git status # Detailed git status',
' bun run format # Format code',
' bun run lint # Lint code',
'═'.repeat(70),
);
return lines.join('\n');
}
/**
* Main hook execution
*/
async function main(): Promise<void> {
const startTime = Date.now();
try {
const input = await parseStdin();
// Output checklist
const checklist = formatChecklist(input.cwd);
console.log(checklist);
// Performance check
checkPerformance(startTime, 100, 'session-checklist');
process.exit(0);
} catch (error) {
console.error(formatError(error, 'session-checklist'));
// Don't fail session start - exit cleanly
process.exit(0);
}
}
// Execute
await main();

326
hooks/session-start.ts Executable file
View File

@@ -0,0 +1,326 @@
#!/usr/bin/env bun
/**
* SessionStart Hook for OpenSpec Workflow
*
* This hook automatically loads context from the active OpenSpec change when
* a new Claude Code session starts or resumes. It reads .openspec/active.json
* to find the current change, then loads the proposal, design, and tasks to
* restore context seamlessly.
*
* @see {@link https://github.com/jbabin91/super-claude} for documentation
*
* Runtime: Bun (native TypeScript support)
* Execution: Triggered on session start/resume
* Performance Target: <100ms
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
/**
* Active change tracker schema
*/
type ActiveChange = {
change: string; // Change ID (folder name)
started: string; // ISO 8601 timestamp
lastCheckpoint: string; // ISO 8601 timestamp
};
/**
* Hook input from Claude Code (via stdin)
*/
type HookInput = {
cwd: string; // Current working directory
[key: string]: unknown; // Other potential fields
};
/**
* Parse hook input from stdin.
*
* @returns Parsed HookInput object
* @throws Error if stdin is invalid
*/
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;
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;
}
}
/**
* Load active change from openspec/active.json
*
* @param cwd Current working directory
* @returns ActiveChange object or null if not found
*/
function loadActiveChange(cwd: string): ActiveChange | null {
const activePath = path.resolve(cwd, 'openspec/active.json');
if (!existsSync(activePath)) {
return null;
}
try {
const content = readFileSync(activePath, 'utf8');
const active = JSON.parse(content) as ActiveChange;
// Validate required fields
if (!active.change || typeof active.change !== 'string') {
console.warn('[WARNING] Invalid active.json: missing change field');
return null;
}
return active;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.warn('[WARNING] Failed to load active.json: ' + msg);
return null;
}
}
/**
* Load file content safely with fallback
*
* @param path File path
* @param fallback Fallback message if file not found
* @returns File content or fallback
*/
function loadFileContent(path: string, fallback: string): string {
if (!existsSync(path)) {
return fallback;
}
try {
return readFileSync(path, 'utf8');
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.warn('[WARNING] Failed to read ' + path + ': ' + msg);
return fallback;
}
}
/**
* Count completed and total tasks from tasks.md
*
* @param tasksContent Content of tasks.md
* @returns Object with total and completed counts
*/
function countTasks(tasksContent: string): {
total: number;
completed: number;
} {
const lines = tasksContent.split('\n');
let total = 0;
let completed = 0;
for (const line of lines) {
if (line.trim().startsWith('- [x]') || line.trim().startsWith('- [X]')) {
completed++;
total++;
} else if (line.trim().startsWith('- [ ]')) {
total++;
}
}
return { total, completed };
}
/**
* Format time elapsed since timestamp
*
* @param timestamp ISO 8601 timestamp
* @returns Human-readable time elapsed
*/
function formatTimeElapsed(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const minutes = Math.floor(diffMs / 1000 / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return days + (days === 1 ? ' day ago' : ' days ago');
if (hours > 0) return hours + (hours === 1 ? ' hour ago' : ' hours ago');
if (minutes > 0)
return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago');
return 'just now';
}
/**
* Get last N lines from content
*
* @param content File content
* @param n Number of lines
* @returns Last N lines
*/
function getLastLines(content: string, n: number): string {
const lines = content.trim().split('\n');
const lastN = lines.slice(-n);
return lastN.join('\n');
}
/**
* Format context output for Claude
*
* @param active Active change data
* @param cwd Current working directory
* @returns Formatted context string
*/
function formatContext(active: ActiveChange, cwd: string): string {
const changeDir = path.resolve(cwd, 'openspec/changes', active.change);
// Check if change directory exists
if (!existsSync(changeDir)) {
return (
'⚠️ Active change "' +
active.change +
'" not found.\n' +
'It may have been archived. Use /openspec:work to select a new change.'
);
}
// Load context files
const proposalPath = path.join(changeDir, 'proposal.md');
const designPath = path.join(changeDir, 'design.md');
const tasksPath = path.join(changeDir, 'tasks.md');
const proposal = loadFileContent(
proposalPath,
'(No proposal.md - create one with /openspec:proposal)',
);
const design = loadFileContent(
designPath,
'(No design.md - create one to track your approach)',
);
const tasks = loadFileContent(tasksPath, '(No tasks.md - no tasks defined)');
// Count task progress
const taskCounts = countTasks(tasks);
const percentage =
taskCounts.total > 0
? Math.round((taskCounts.completed / taskCounts.total) * 100)
: 0;
// Format timestamps
const startedAgo = formatTimeElapsed(active.started);
const checkpointAgo = active.lastCheckpoint
? formatTimeElapsed(active.lastCheckpoint)
: 'never';
// Build output
const recentContext = getLastLines(design, 15);
const lines = [
'='.repeat(70),
'📋 RESUMING OPENSPEC WORK',
'='.repeat(70),
'',
'Active Change: ' + active.change,
'Started: ' + startedAgo,
'Last Checkpoint: ' + checkpointAgo,
'',
'Progress:',
' Tasks: ' +
taskCounts.completed +
'/' +
taskCounts.total +
' (' +
percentage +
'%)',
'',
'-'.repeat(70),
'PROPOSAL (WHY)',
'-'.repeat(70),
proposal,
'',
'-'.repeat(70),
'DESIGN (HOW - Living Doc)',
'-'.repeat(70),
design,
'',
'-'.repeat(70),
'RECENT CHECKPOINT NOTES',
'-'.repeat(70),
recentContext,
'',
'-'.repeat(70),
'TASKS (WHAT)',
'-'.repeat(70),
tasks,
'',
'='.repeat(70),
'COMMANDS:',
' /openspec:status - Check progress',
' /openspec:checkpoint - Save progress',
' /openspec:work - Switch changes',
' /openspec:done - Complete and archive',
'='.repeat(70),
];
return lines.join('\n');
}
/**
* Main hook execution
*
* Workflow:
* 1. Parse stdin
* 2. Load active change
* 3. If active, load context files
* 4. Format and output context
* 5. Exit cleanly
*/
async function main(): Promise<void> {
const startTime = Date.now();
try {
// 1. Parse input
const input = await parseStdin();
// 2. Load active change
const active = loadActiveChange(input.cwd);
// If no active change, exit silently (no output)
if (!active) {
process.exit(0);
}
// 3. Format and output context
const context = formatContext(active, input.cwd);
console.log(context);
// Performance monitoring
const duration = Date.now() - startTime;
if (duration > 100) {
console.warn('[WARNING] Slow SessionStart hook: ' + duration + 'ms');
}
process.exit(0);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.error('[ERROR] SessionStart hook error: ' + msg);
// Don't fail the session start - just exit silently
process.exit(0);
}
}
// Execute
await main();

518
hooks/skill-activation-prompt.ts Executable file
View File

@@ -0,0 +1,518 @@
#!/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();

324
hooks/type-checker.ts Executable file
View File

@@ -0,0 +1,324 @@
#!/usr/bin/env bun
/**
* Type Checker Hook
*
* Pre-validates TypeScript types before Edit/Write operations.
* Uses @jbabin91/tsc-files programmatic API for incremental type checking with tsgo support.
* Informs about type errors but allows file modifications (informative only).
*
* Smart behavior:
* - Skips non-TypeScript files (no performance impact)
* - Skips projects without tsconfig.json
* - Dynamically imports type checker only when needed
*
* Performance: ~100-200ms with tsgo (10x faster than tsc)
* Performance target: <2s (ADR-0010) - typically well under this
* Hook behavior: Informative (does not block execution)
*
* @see {@link https://github.com/jbabin91/super-claude} for documentation
*/
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { CheckResult } from '@jbabin91/tsc-files';
import {
checkHookEnabled,
checkPerformance,
ensureBunInstalled,
formatError,
parseStdin,
} from './utils/index.js';
/**
* Check if file is TypeScript
*
* @param filePath File path to check
* @returns true if TypeScript file
*/
function isTypeScriptFile(filePath: string): boolean {
return /\.(ts|tsx)$/.test(filePath);
}
/**
* Check if tsconfig.json exists
*
* @param cwd Current working directory
* @returns true if tsconfig.json found
*/
function hasTsConfig(cwd: string): boolean {
return (
existsSync(path.join(cwd, 'tsconfig.json')) ||
existsSync(path.join(cwd, 'tsconfig.base.json'))
);
}
/**
* Run TypeScript type checking on file using programmatic API
*
* @param cwd Current working directory
* @param filePath File path to check
* @returns CheckResult with structured error data
*/
async function checkTypes(cwd: string, filePath: string): Promise<CheckResult> {
try {
// Dynamically import checkFiles only when needed
// This prevents loading TypeScript dependencies for non-TS files
const module = await import('@jbabin91/tsc-files');
// Defensive check: ensure checkFiles exists after import
if (!module.checkFiles || typeof module.checkFiles !== 'function') {
throw new Error(
'Type checker module loaded but checkFiles function not found. ' +
'This may indicate a version mismatch. ' +
'Try: bun install @jbabin91/tsc-files@latest',
);
}
const { checkFiles } = module;
// Use programmatic API for structured error data
// Automatically uses tsgo if available (10x faster)
const result = await checkFiles([filePath], {
cwd,
skipLibCheck: true,
verbose: false,
throwOnError: false,
});
return result;
} catch (error: unknown) {
// Handle different error scenarios with helpful messages
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if this is a module not found error
const isModuleNotFound =
errorMessage.includes('Cannot find package') ||
errorMessage.includes('Cannot find module') ||
errorMessage.includes('@jbabin91/tsc-files');
let helpfulMessage = errorMessage;
if (isModuleNotFound) {
helpfulMessage =
'Type checker dependency not installed.\n\n' +
'To enable type checking, install the required package:\n' +
' bun install @jbabin91/tsc-files\n\n' +
'Or disable this hook in .claude/super-claude-config.json:\n' +
' "workflow": { "hooks": { "typeChecker": { "enabled": false } } }\n\n' +
`Original error: ${errorMessage}`;
}
// Return a failed result with helpful error message
return {
success: false,
errorCount: 1,
warningCount: 0,
errors: [
{
file: filePath,
line: 0,
column: 0,
message: helpfulMessage,
code: isModuleNotFound ? 'HOOK_ERROR' : 'TS0000',
severity: 'error',
},
],
warnings: [],
checkedFiles: [filePath],
duration: 0,
};
}
}
/**
* Format type errors for display with categorization
*
* @param result CheckResult from tsc-files API
* @param targetFile The file being edited
* @returns Formatted error message
*/
function formatTypeErrors(result: CheckResult, targetFile: string): string {
// Check if this is a hook error (not a type error)
const hasHookError = result.errors.some((e) => e.code === 'HOOK_ERROR');
if (hasHookError) {
// Format hook configuration errors differently
const hookError = result.errors.find((e) => e.code === 'HOOK_ERROR');
return [
'',
'═'.repeat(70),
'⚠️ TYPE CHECKER HOOK ERROR',
'═'.repeat(70),
'',
hookError?.message ?? 'Unknown hook error',
'',
'═'.repeat(70),
'',
].join('\n');
}
// Categorize errors by file
const targetFileErrors = result.errors.filter((e) => e.file === targetFile);
const dependencyErrors = result.errors.filter((e) => e.file !== targetFile);
// Header
const sections: string[] = [
'',
'═'.repeat(70),
'⚠️ TYPE ERRORS DETECTED - ACTION REQUIRED',
'═'.repeat(70),
'',
];
// Target file errors (critical)
if (targetFileErrors.length > 0) {
sections.push(
'🎯 ERRORS IN THIS FILE:',
` File: ${targetFile}`,
' Action: Fix these before proceeding to next task',
'',
);
for (const err of targetFileErrors.slice(0, 10)) {
sections.push(
` ${err.file}:${err.line}:${err.column}`,
` ${err.code}: ${err.message}`,
'',
);
}
if (targetFileErrors.length > 10) {
sections.push(
` ... and ${targetFileErrors.length - 10} more errors in this file`,
'',
);
}
}
// Dependency errors (informational)
if (dependencyErrors.length > 0) {
sections.push(
'─'.repeat(70),
' ERRORS IN DEPENDENCIES:',
' These errors are in imported files',
' Fix them separately or add to your todo list',
'',
);
// Group by file
const byFile = new Map<string, CheckResult['errors'][number][]>();
for (const err of dependencyErrors) {
if (!byFile.has(err.file)) {
byFile.set(err.file, []);
}
byFile.get(err.file)!.push(err);
}
let fileCount = 0;
for (const [file, errors] of byFile.entries()) {
if (fileCount >= 5) break;
sections.push(` 📄 ${file} (${errors.length} errors)`);
fileCount++;
}
if (byFile.size > 5) {
sections.push(` ... and ${byFile.size - 5} more files`);
}
sections.push('');
}
// Footer with workflow guidance
sections.push(
'─'.repeat(70),
'🤖 CLAUDE: Type errors detected.',
'',
'Recommended workflow:',
' 1. If working on a task: Add "Fix type errors" to your todo list',
' 2. Complete your current task first',
' 3. Then fix these type errors before moving to next task',
'',
'If the type error is directly related to your current edit:',
' → Fix it immediately as part of this change',
'',
'User: To disable this hook, add to .claude/settings.json:',
' { "customHooks": { "typeChecker": { "enabled": false } } }',
'═'.repeat(70),
'',
);
return sections.join('\n');
}
/**
* Main hook execution
*/
async function main(): Promise<void> {
const startTime = Date.now();
try {
// Ensure Bun is installed (fail fast with helpful message)
ensureBunInstalled();
const input = await parseStdin();
// Check if hook is enabled
checkHookEnabled(input.cwd, 'typeChecker');
// Only run for Edit and Write tools
if (input.tool_name !== 'Edit' && input.tool_name !== 'Write') {
process.exit(0); // Not a file modification tool
}
// Extract file path from tool input
const toolInput = input.tool_input!;
const filePath = toolInput?.file_path as string | undefined;
if (!filePath) {
process.exit(0); // No file path
}
// Skip if not TypeScript file
if (!isTypeScriptFile(filePath)) {
process.exit(0);
}
// Skip if no tsconfig
if (!hasTsConfig(input.cwd)) {
process.exit(0);
}
// Check types using programmatic API
const result = await checkTypes(input.cwd, filePath);
if (!result.success) {
// Type errors found - inform but allow operation
const errorMessage = formatTypeErrors(result, filePath);
// Output hookSpecificOutput to inform (not block)
const output = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow', // Informative only
permissionDecisionReason: errorMessage,
},
};
console.log(JSON.stringify(output));
checkPerformance(startTime, 2000, 'type-checker');
process.exit(0); // Exit 0 after informing
}
// Types are valid - allow operation silently
checkPerformance(startTime, 2000, 'type-checker');
process.exit(0);
} catch (error) {
console.error(formatError(error, 'type-checker'));
// On hook error, don't block the operation
process.exit(0);
}
}
// Execute
await main();

View File

@@ -0,0 +1,190 @@
/**
* Hook Configuration Loader
*
* Loads hook configuration from multiple sources with priority:
* 1. Environment variables (CLAUDE_HOOK_{NAME}_ENABLED) - session override
* 2. .claude/settings.local.json (gitignored, personal overrides)
* 3. .claude/settings.json (project, committed)
* 4. .claude/super-claude-config.json (unified plugin config)
* 5. ~/.claude/settings.json (global user defaults)
* 6. Plugin defaults (enabled: true)
*
* Supports both native Claude Code settings (customHooks) and unified plugin config (workflow.hooks).
*
* @see {@link https://docs.claude.com/en/docs/claude-code/settings} for settings hierarchy
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
/**
* Hook configuration schema
*/
export type HookConfig = {
enabled: boolean;
[key: string]: unknown; // Allow hook-specific config
};
/**
* Settings file schema (partial - only customHooks)
*/
type SettingsFile = {
customHooks?: Record<string, HookConfig>;
[key: string]: unknown;
};
/**
* Unified plugin config schema (partial - only workflow.hooks)
*/
type UnifiedConfig = {
workflow?: {
hooks?: Record<string, HookConfig>;
[key: string]: unknown;
};
[key: string]: unknown;
};
/**
* Load settings file safely
*
* @param filePath Path to settings.json file
* @returns Parsed settings or null if not found/invalid
*/
function loadSettingsFile(filePath: string): SettingsFile | null {
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content) as SettingsFile;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`);
return null;
}
}
/**
* Load unified plugin config file safely
*
* @param filePath Path to super-claude-config.json file
* @returns Parsed config or null if not found/invalid
*/
function loadUnifiedConfig(filePath: string): UnifiedConfig | null {
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content) as UnifiedConfig;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`[WARNING] Failed to load ${filePath}: ${msg}`);
return null;
}
}
/**
* Load hook configuration from settings hierarchy
*
* Configuration priority (highest to lowest):
* 1. Environment variables (CLAUDE_HOOK_{HOOK_NAME}_ENABLED)
* 2. Local overrides (.claude/settings.local.json)
* 3. Project settings (.claude/settings.json)
* 4. Unified plugin config (.claude/super-claude-config.json)
* 5. Global settings (~/.claude/settings.json)
* 6. Default (enabled: true)
*
* @param cwd Current working directory
* @param hookName Hook name (e.g., 'gitCommitGuard')
* @returns Hook configuration
*
* @example
* ```ts
* const config = loadHookConfig(cwd, 'gitCommitGuard');
* if (!config.enabled) {
* console.log('Hook disabled by config');
* process.exit(0);
* }
* ```
*/
export function loadHookConfig(cwd: string, hookName: string): HookConfig {
// Load all config files
const localPath = path.join(cwd, '.claude', 'settings.local.json');
const projectPath = path.join(cwd, '.claude', 'settings.json');
const unifiedPath = path.join(cwd, '.claude', 'super-claude-config.json');
const globalPath = path.join(
process.env.HOME ?? process.env.USERPROFILE ?? '~',
'.claude',
'settings.json',
);
const local = loadSettingsFile(localPath);
const project = loadSettingsFile(projectPath);
const unified = loadUnifiedConfig(unifiedPath);
const global = loadSettingsFile(globalPath);
// Check environment variable override
const envKey = `CLAUDE_HOOK_${hookName.toUpperCase()}_ENABLED`;
const envEnabled = process.env[envKey];
// Merge config in reverse priority order (lowest to highest)
const config: HookConfig = { enabled: true };
// 1. Start with global settings
if (global?.customHooks?.[hookName]) {
Object.assign(config, global.customHooks[hookName]);
}
// 2. Override with unified plugin config
if (unified?.workflow?.hooks?.[hookName]) {
Object.assign(config, unified.workflow.hooks[hookName]);
}
// 3. Override with project settings
if (project?.customHooks?.[hookName]) {
Object.assign(config, project.customHooks[hookName]);
}
// 4. Override with local settings
if (local?.customHooks?.[hookName]) {
Object.assign(config, local.customHooks[hookName]);
}
// 5. Override with environment variable (highest priority)
if (envEnabled !== undefined) {
config.enabled = envEnabled === 'true' || envEnabled === '1';
}
return config;
}
/**
* Check if hook is enabled
*
* Convenience function that exits cleanly if hook is disabled.
* Call this at the start of your hook to respect user configuration.
*
* @param cwd Current working directory
* @param hookName Hook name
* @returns true if enabled, never returns if disabled (exits process)
*
* @example
* ```ts
* const input = await parseStdin();
* checkHookEnabled(input.cwd, 'gitCommitGuard'); // Exits if disabled
* // Continue with hook logic...
* ```
*/
export function checkHookEnabled(cwd: string, hookName: string): boolean {
const config = loadHookConfig(cwd, hookName);
if (!config.enabled) {
// Exit cleanly without output (hook disabled)
process.exit(0);
}
return true;
}

117
hooks/utils/config-types.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Type definitions for super-claude-config.json
*
* Supports both plugin-level defaults and project-level overrides
* with deep merge behavior.
*/
/**
* Skill auto-activation triggers
*/
export type SkillTriggers = {
/** Literal keywords for case-insensitive matching */
keywords?: string[];
/** Regex patterns for intent matching */
patterns?: string[];
};
/**
* Complete skill configuration (plugin defaults)
*/
export type SkillConfig = {
/** Whether this skill is enabled */
enabled?: boolean;
/** Auto-activation triggers */
triggers?: SkillTriggers;
/** Additional skill-specific settings */
[key: string]: unknown;
};
/**
* Partial skill configuration (project overrides)
*/
export type SkillOverride = Partial<SkillConfig>;
/**
* Hook configuration with plugin-specific settings
*/
export type HookConfig = {
/** Whether this hook is enabled */
enabled?: boolean;
/** Additional hook-specific settings */
[key: string]: unknown;
};
/**
* Partial hook configuration (project overrides)
*/
export type HookOverride = Partial<HookConfig>;
/**
* Plugin-level configuration (plugins/{plugin}/super-claude-config.json)
*/
export type PluginConfig = {
/** Plugin identifier matching directory name */
plugin: string;
/** Skill configurations keyed by skill name */
skills?: Record<string, SkillConfig>;
/** Hook configurations keyed by hook name */
hooks?: Record<string, HookConfig>;
};
/**
* Project-level configuration (.claude/super-claude-config.json)
*
* Structure: { [pluginName]: { skills: {...}, hooks: {...} } }
*/
export type ProjectConfig = Record<
string,
{
skills?: Record<string, SkillOverride>;
hooks?: Record<string, HookOverride>;
}
>;
/**
* Resolved configuration after merging defaults and overrides
*/
export type ResolvedConfig = {
skills: Record<string, SkillConfig>;
hooks: Record<string, HookConfig>;
};
/**
* Configuration loading options
*/
export type ConfigLoaderOptions = {
/** Current working directory */
cwd: string;
/** Plugin name to load config for */
pluginName: string;
/** Whether to cache loaded configuration */
cache?: boolean;
};
/**
* Legacy skill-rules.json format for backwards compatibility
*/
export type LegacySkillRules = {
plugin: {
namespace: string;
name: string;
};
skills: Record<
string,
{
name: string;
priority?: 'critical' | 'high' | 'medium' | 'low';
promptTriggers?: {
keywords?: string[];
intentPatterns?: string[];
};
}
>;
overrides?: {
disabled?: string[];
};
};

View File

@@ -0,0 +1,181 @@
/**
* Runtime validation schemas for super-claude-config.json
*
* Uses ArkType for TypeScript-native validation with excellent error messages.
*/
import { type } from 'arktype';
import type {
HookConfig,
LegacySkillRules,
PluginConfig,
ProjectConfig,
SkillConfig,
} from './config-types.js';
/**
* Skill configuration validation schema
*/
export const skillConfigSchema = type({
'enabled?': 'boolean',
'triggers?': 'object',
});
/**
* Hook configuration validation schema
*
* Allows additional properties for hook-specific settings
*/
export const hookConfigSchema = type({
'enabled?': 'boolean',
});
/**
* Plugin-level configuration validation schema
*/
export const pluginConfigSchema = type({
plugin: 'string',
'skills?': 'object',
'hooks?': 'object',
});
/**
* Legacy skill-rules.json validation schema
*/
export const legacySkillRulesSchema = type({
plugin: {
namespace: 'string',
name: 'string',
},
skills: 'object',
'overrides?': {
'disabled?': 'string[]',
},
});
/**
* Validation result type
*/
export type ValidationResult<T> =
| { success: true; data: T }
| { success: false; errors: string };
/**
* Validate plugin configuration
*
* @param data Configuration data to validate
* @returns Validation result with typed data or errors
*/
export function validatePluginConfig(
data: unknown,
): ValidationResult<PluginConfig> {
const result = pluginConfigSchema(data);
if (result instanceof type.errors) {
return {
success: false,
errors: result.summary,
};
}
return {
success: true,
data: result as PluginConfig,
};
}
/**
* Validate project configuration
*
* @param data Configuration data to validate
* @returns Validation result with typed data or errors
*/
export function validateProjectConfig(
data: unknown,
): ValidationResult<ProjectConfig> {
// Basic object check
if (typeof data !== 'object' || data === null) {
return {
success: false,
errors: 'Project configuration must be an object',
};
}
return {
success: true,
data: data as ProjectConfig,
};
}
/**
* Validate skill configuration
*
* @param data Skill config data to validate
* @returns Validation result with typed data or errors
*/
export function validateSkillConfig(
data: unknown,
): ValidationResult<SkillConfig> {
const result = skillConfigSchema(data);
if (result instanceof type.errors) {
return {
success: false,
errors: result.summary,
};
}
return {
success: true,
data: result as SkillConfig,
};
}
/**
* Validate hook configuration
*
* @param data Hook config data to validate
* @returns Validation result with typed data or errors
*/
export function validateHookConfig(
data: unknown,
): ValidationResult<HookConfig> {
const result = hookConfigSchema(data);
if (result instanceof type.errors) {
return {
success: false,
errors: result.summary,
};
}
return {
success: true,
data: result as HookConfig,
};
}
/**
* Validate legacy skill-rules.json format
*
* @param data Legacy config data to validate
* @returns Validation result with typed data or errors
*/
export function validateLegacySkillRules(
data: unknown,
): ValidationResult<LegacySkillRules> {
const result = legacySkillRulesSchema(data);
if (result instanceof type.errors) {
return {
success: false,
errors: result.summary,
};
}
return {
success: true,
data: result as LegacySkillRules,
};
}

94
hooks/utils/hook-input.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Hook Input Parsing Utilities
*
* Shared utilities for parsing Claude Code hook input from stdin.
* All hooks receive JSON via stdin with fields like cwd, tool_name, tool_input, etc.
*
* @see {@link https://docs.claude.com/en/docs/claude-code/hooks} for hook input spec
*/
/**
* Standard hook input schema from Claude Code
*/
export type HookInput = {
cwd: string; // Current working directory
tool_name?: string; // Tool being invoked (PreToolUse/PostToolUse)
tool_input?: Record<string, unknown>; // Tool parameters
transcript_path?: string; // Path to conversation transcript JSON
[key: string]: unknown; // Allow additional fields
};
/**
* Parse hook input from stdin.
*
* Reads JSON from stdin and validates required fields.
* Throws error for invalid input to fail fast.
*
* @returns Parsed HookInput object
* @throws Error if stdin is invalid or missing required fields
*
* @example
* ```ts
* const input = await parseStdin();
* console.log('Working directory:', input.cwd);
* ```
*/
export 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;
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;
}
}
/**
* Safe error formatting for hook output
*
* Formats error for display to user without leaking implementation details.
*
* @param error Error object or unknown
* @param prefix Optional prefix for error message
* @returns Formatted error string
*/
export function formatError(error: unknown, prefix = 'Hook error'): string {
const msg = error instanceof Error ? error.message : 'Unknown error';
return `[ERROR] ${prefix}: ${msg}`;
}
/**
* Performance monitoring helper
*
* Logs warning if execution exceeds target duration.
* Use to enforce ADR-0010 performance requirements (<50ms command hooks).
*
* @param startTime Start time from Date.now()
* @param targetMs Target duration in milliseconds
* @param hookName Name of hook for warning message
*/
export function checkPerformance(
startTime: number,
targetMs: number,
hookName: string,
): void {
const duration = Date.now() - startTime;
if (duration > targetMs) {
console.warn(
`[WARNING] Slow ${hookName} hook: ${duration}ms (target: ${targetMs}ms)`,
);
}
}

23
hooks/utils/index.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Hook Utilities
*
* Shared utilities for Claude Code command hooks.
* Includes input parsing, configuration loading, error handling, and runtime checks.
*/
export {
checkHookEnabled,
type HookConfig,
loadHookConfig,
} from './config-loader.js';
export {
checkPerformance,
formatError,
type HookInput,
parseStdin,
} from './hook-input.js';
export {
checkBunVersion,
ensureBunInstalled,
ensureToolsInstalled,
} from './runtime-check.js';

View File

@@ -0,0 +1,146 @@
/**
* Runtime Check Utilities
*
* Validates runtime requirements for hooks (e.g., Bun installation).
* Provides clear, actionable error messages when requirements aren't met.
*/
import { execSync } from 'node:child_process';
/**
* Check if Bun is installed and available in PATH
*
* @throws Error with installation instructions if Bun is not found
*/
export function ensureBunInstalled(): void {
try {
// Use 'bun --version' for cross-platform compatibility (works on Windows, macOS, Linux)
execSync('bun --version', { stdio: 'pipe' });
} catch {
const errorMessage = [
'',
'═'.repeat(70),
'❌ BUN RUNTIME NOT FOUND',
'═'.repeat(70),
'',
'This workflow hook requires Bun to be installed.',
'',
'📦 Install Bun:',
'',
' macOS/Linux:',
' curl -fsSL https://bun.sh/install | bash',
'',
' Windows:',
' powershell -c "irm bun.sh/install.ps1|iex"',
'',
' Or via npm:',
' npm install -g bun',
'',
' Or via Homebrew (macOS):',
' brew install oven-sh/bun/bun',
'',
'🔗 More info: https://bun.sh',
'',
'⚠️ After installing, restart your terminal and try again.',
'',
'═'.repeat(70),
'',
].join('\n');
console.error(errorMessage);
process.exit(1);
}
}
/**
* Check Bun version meets minimum requirement
*
* @param minVersion Minimum required version (e.g., "1.0.0")
* @returns true if version is sufficient, false otherwise
*/
export function checkBunVersion(minVersion: string): boolean {
try {
const version = execSync('bun --version', {
encoding: 'utf8',
stdio: 'pipe',
}).trim();
return compareVersions(version, minVersion) >= 0;
} catch {
return false;
}
}
/**
* Strip pre-release and build metadata from semver string
*
* @param version Version string (e.g., "1.0.0-beta", "1.0.0+build")
* @returns Clean version (e.g., "1.0.0")
*/
function stripSemverMetadata(version: string): string {
return version.split(/[-+]/)[0] || version;
}
/**
* Compare two semantic versions
*
* Strips pre-release and build metadata before comparison.
* Examples: "1.0.0-beta" → "1.0.0", "1.0.0+build" → "1.0.0"
*
* @param a Version string (e.g., "1.2.3", "1.2.3-beta")
* @param b Version string (e.g., "1.0.0", "1.0.0+build")
* @returns -1 if a < b, 0 if equal, 1 if a > b
*/
function compareVersions(a: string, b: string): number {
const aParts = stripSemverMetadata(a).split('.').map(Number);
const bParts = stripSemverMetadata(b).split('.').map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0;
const bPart = bParts[i] || 0;
if (aPart > bPart) return 1;
if (aPart < bPart) return -1;
}
return 0;
}
/**
* Ensure required command-line tools are installed
*
* @param tools Array of required tools (e.g., ['git', 'tsc'])
* @throws Error with installation instructions if any tool is missing
*/
export function ensureToolsInstalled(tools: string[]): void {
const missing: string[] = [];
// Use platform-specific command: 'where' on Windows, 'which' on Unix-like systems
const checkCommand = process.platform === 'win32' ? 'where' : 'which';
for (const tool of tools) {
try {
execSync(`${checkCommand} ${tool}`, { stdio: 'pipe' });
} catch {
missing.push(tool);
}
}
if (missing.length > 0) {
const errorMessage = [
'',
'═'.repeat(70),
'❌ MISSING REQUIRED TOOLS',
'═'.repeat(70),
'',
`The following tools are required but not found: ${missing.join(', ')}`,
'',
'Please install the missing tools and try again.',
'',
'═'.repeat(70),
'',
].join('\n');
console.error(errorMessage);
process.exit(1);
}
}

View File

@@ -0,0 +1,430 @@
/**
* Super Claude Configuration Loader
*
* Loads unified plugin configuration from super-claude-config.json files
* with support for:
* - Plugin defaults (plugins/{plugin}/super-claude-config.json)
* - Project overrides (.claude/super-claude-config.json)
* - Legacy skill-rules.json backwards compatibility
* - Environment variable overrides (highest priority)
* - Deep merge with caching for performance (<50ms)
*
* Loading priority: env vars > project overrides > plugin defaults
*
* @see {@link https://github.com/jbabin91/super-claude} for documentation
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type {
HookConfig,
LegacySkillRules,
PluginConfig,
ProjectConfig,
ResolvedConfig,
SkillConfig,
} from './config-types.js';
import {
validateLegacySkillRules,
validatePluginConfig,
validateProjectConfig,
} from './config-validation.js';
/**
* Configuration cache for performance
* Key: pluginName:cwd
*/
const configCache = new Map<string, ResolvedConfig>();
/**
* Deep merge two objects
*
* Arrays are replaced entirely (not merged item-by-item)
* Nested objects are merged recursively
*
* @param target Target object
* @param source Source object with overrides
* @returns Merged object
*/
function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Partial<T>,
): T {
const result = { ...target };
for (const key in source) {
const sourceValue = source[key];
const targetValue = result[key];
if (sourceValue === undefined) {
continue;
}
// Arrays: replace entirely
if (Array.isArray(sourceValue)) {
result[key] = sourceValue as T[Extract<keyof T, string>];
continue;
}
// Objects: merge recursively
if (
typeof sourceValue === 'object' &&
sourceValue !== null &&
typeof targetValue === 'object' &&
targetValue !== null &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
) as T[Extract<keyof T, string>];
continue;
}
// Primitives: replace
result[key] = sourceValue as T[Extract<keyof T, string>];
}
return result;
}
/**
* Load JSON file safely
*
* @param filePath Path to JSON file
* @returns Parsed JSON or null if not found/invalid
*/
function loadJsonFile(filePath: string): unknown {
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content) as unknown;
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
console.error(`[ERROR] Failed to load ${filePath}: ${msg}`);
return null;
}
}
/**
* Find plugin root directory
*
* Searches upward from current file to find plugins directory
*
* @param cwd Current working directory
* @returns Path to plugin root or null if not found
*/
function findPluginRoot(cwd: string): string | null {
// Try to find plugins directory by searching upward
let current = cwd;
const root = path.parse(current).root;
while (current !== root) {
const pluginsDir = path.join(current, 'plugins');
if (existsSync(pluginsDir)) {
return current;
}
current = path.dirname(current);
}
return null;
}
/**
* Load plugin default configuration
*
* Tries to load from:
* 1. plugins/{plugin}/super-claude-config.json
* 2. plugins/{plugin}/skill-rules.json (legacy, with warning)
*
* @param cwd Current working directory
* @param pluginName Plugin name
* @returns Plugin configuration or empty config
*/
function loadPluginDefaults(
cwd: string,
pluginName: string,
): Partial<PluginConfig> {
const pluginRoot = findPluginRoot(cwd);
if (!pluginRoot) {
console.error(
`[WARNING] Could not find plugin root from ${cwd}, using empty defaults`,
);
return { plugin: pluginName, skills: {}, hooks: {} };
}
const pluginDir = path.join(pluginRoot, 'plugins', pluginName);
// Try new format first
const configPath = path.join(pluginDir, 'super-claude-config.json');
const configData = loadJsonFile(configPath);
if (configData) {
const result = validatePluginConfig(configData);
if (!result.success) {
console.error(
`[ERROR] Invalid plugin config in ${pluginName}:\n${result.errors}`,
);
return { plugin: pluginName, skills: {}, hooks: {} };
}
return result.data;
}
// Try legacy format
const legacyPath = path.join(pluginDir, 'skill-rules.json');
const legacyData = loadJsonFile(legacyPath);
if (legacyData) {
console.warn(
`[DEPRECATION] ${pluginName} using deprecated skill-rules.json. Migrate to super-claude-config.json`,
);
const result = validateLegacySkillRules(legacyData);
if (!result.success) {
console.error(
`[ERROR] Invalid legacy config in ${pluginName}:\n${result.errors}`,
);
return { plugin: pluginName, skills: {}, hooks: {} };
}
// Convert legacy format to new format
return convertLegacyFormat(result.data, pluginName);
}
// No config found, use empty defaults
return { plugin: pluginName, skills: {}, hooks: {} };
}
/**
* Convert legacy skill-rules.json to new format
*
* @param legacy Legacy skill rules
* @param pluginName Plugin name
* @returns Plugin configuration
*/
function convertLegacyFormat(
legacy: LegacySkillRules,
pluginName: string,
): Partial<PluginConfig> {
const skills: Record<string, SkillConfig> = {};
for (const [skillName, skillData] of Object.entries(legacy.skills)) {
skills[skillName] = {
enabled: !legacy.overrides?.disabled?.includes(
`${legacy.plugin.namespace}/${skillName}`,
),
triggers: {
keywords: skillData.promptTriggers?.keywords ?? [],
patterns: skillData.promptTriggers?.intentPatterns ?? [],
},
};
}
return {
plugin: pluginName,
skills,
hooks: {},
};
}
/**
* Load project override configuration
*
* Loads from .claude/super-claude-config.json
*
* @param cwd Current working directory
* @returns Project configuration or null
*/
function loadProjectOverrides(cwd: string): ProjectConfig | null {
const overridePath = path.join(cwd, '.claude', 'super-claude-config.json');
const overrideData = loadJsonFile(overridePath);
if (!overrideData) {
return null;
}
const result = validateProjectConfig(overrideData);
if (!result.success) {
console.error(`[ERROR] Invalid project config:\n${result.errors}`);
return null;
}
return result.data;
}
/**
* Apply environment variable overrides
*
* Environment variables have highest priority.
* Format: CLAUDE_HOOK_{HOOK_NAME}_ENABLED=true|false
*
* @param config Configuration to modify
* @param pluginName Plugin name (for logging)
*/
function applyEnvironmentOverrides(
config: ResolvedConfig,
pluginName: string,
): void {
// Hook enabled overrides
for (const hookName of Object.keys(config.hooks)) {
const envKey = `CLAUDE_HOOK_${hookName.replaceAll(/[A-Z]/g, (m) => `_${m}`).toUpperCase()}`;
const envValue = process.env[envKey];
if (envValue !== undefined) {
config.hooks[hookName].enabled = envValue === 'true' || envValue === '1';
console.error(
`[DEBUG] ${pluginName}:${hookName} enabled=${config.hooks[hookName].enabled} (from ${envKey})`,
);
}
}
}
/**
* Load and merge configuration for a plugin
*
* Merge order: plugin defaults → project overrides → env vars
*
* @param cwd Current working directory
* @param pluginName Plugin name
* @param useCache Whether to use cached config
* @returns Resolved configuration
*/
export function loadPluginConfig(
cwd: string,
pluginName: string,
useCache = true,
): ResolvedConfig {
const cacheKey = `${pluginName}:${cwd}`;
// Check cache
if (useCache && configCache.has(cacheKey)) {
return configCache.get(cacheKey)!;
}
// Load plugin defaults
const pluginDefaults = loadPluginDefaults(cwd, pluginName);
// Load project overrides
const projectOverrides = loadProjectOverrides(cwd);
// Start with plugin defaults
const resolved: ResolvedConfig = {
skills: pluginDefaults.skills ?? {},
hooks: pluginDefaults.hooks ?? {},
};
// Apply project overrides for this plugin
if (projectOverrides?.[pluginName]) {
const pluginOverrides = projectOverrides[pluginName];
if (pluginOverrides.skills) {
for (const [skillName, skillOverride] of Object.entries(
pluginOverrides.skills,
)) {
const defaultSkill = resolved.skills[skillName] ?? {
enabled: true,
triggers: { keywords: [], patterns: [] },
};
resolved.skills[skillName] = deepMerge(defaultSkill, skillOverride);
}
}
if (pluginOverrides.hooks) {
for (const [hookName, hookOverride] of Object.entries(
pluginOverrides.hooks,
)) {
const defaultHook = resolved.hooks[hookName] ?? { enabled: true };
resolved.hooks[hookName] = deepMerge(defaultHook, hookOverride);
}
}
}
// Apply environment variable overrides (highest priority)
applyEnvironmentOverrides(resolved, pluginName);
// Cache result
configCache.set(cacheKey, resolved);
return resolved;
}
/**
* Get hook configuration
*
* @param cwd Current working directory
* @param pluginName Plugin name
* @param hookName Hook name
* @returns Hook configuration
*/
export function getHookConfig(
cwd: string,
pluginName: string,
hookName: string,
): HookConfig {
const config = loadPluginConfig(cwd, pluginName);
return config.hooks[hookName] ?? { enabled: true };
}
/**
* Get skill configuration
*
* @param cwd Current working directory
* @param pluginName Plugin name
* @param skillName Skill name
* @returns Skill configuration
*/
export function getSkillConfig(
cwd: string,
pluginName: string,
skillName: string,
): SkillConfig {
const config = loadPluginConfig(cwd, pluginName);
return (
config.skills[skillName] ?? {
enabled: true,
triggers: { keywords: [], patterns: [] },
}
);
}
/**
* Check if hook is enabled
*
* Convenience function that exits cleanly if hook is disabled.
*
* @param cwd Current working directory
* @param pluginName Plugin name
* @param hookName Hook name
* @returns true if enabled, exits if disabled
*/
export function checkHookEnabled(
cwd: string,
pluginName: string,
hookName: string,
): boolean {
const config = getHookConfig(cwd, pluginName, hookName);
if (!config.enabled) {
console.error(`[DEBUG] ${pluginName}:${hookName} disabled by config`);
process.exit(0);
}
return true;
}
/**
* Clear configuration cache
*
* Useful for testing or when config files are modified
*/
export function clearConfigCache(): void {
configCache.clear();
}