From claude-code-hooks
Load this skill immediately after a user mentions "@goodfoot/claude-code-hooks" or Claude Code hooks.
How this skill is triggered — by the user, by Claude, or both
Slash command
/claude-code-hooks:sdkThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Ask the `claude-code-guide` subagent about the hooks for your task or review the authoritative documentation at `https://code.claude.com/docs/en/hooks.md` before using `@goodfoot/claude-code-hooks`.**
Ask the claude-code-guide subagent about the hooks for your task or review the authoritative documentation at https://code.claude.com/docs/en/hooks.md before using @goodfoot/claude-code-hooks.
Hooks are compiled executables, not scripts. You must build them before Claude can see them.
The Build Command:
npx -y @goodfoot/claude-code-hooks -i "hooks/*.ts" -o "dist/hooks.json"
Parameters Explained:
-i "hooks/*.ts": Input Glob. This tells the compiler where your TypeScript source files are.
"...") to prevent your shell from expanding it before the CLI sees it.-o "dist/hooks.json": Output Manifest. This is the file you register in your config.
bin/ folder next to this file containing the compiled .mjs executables. As of 1.7, bundles use stable, hash-free filenames (<name>.mjs) by default.--log "/tmp/hooks.log" (Optional): Hardcoded Log Path. Bakes the log file path into the compiled bundle. A runtime CLAUDE_CODE_HOOKS_LOG_FILE env var overrides it. Cannot be combined with --log-env-var.--log-env-var MY_VAR (Optional): Dynamic Log Path. Bakes an env var name into the bundle; Logger reads process.env[MY_VAR] at startup. Use when the log path varies at runtime (e.g. across git worktrees). Cannot be combined with --log.--loader .ext=type (Optional, repeatable): Explicit Asset Loader. Registers esbuild loaders for non-code imports used by hooks. The compiler ships with .md=text enabled by default, so markdown prompt assets can be imported without extra flags. For other extensions, opt in explicitly, e.g. --loader .txt=text.--stable-names (default) / --no-stable-names (Optional): Filename Stability. Stable mode emits <name>.mjs, keeping the generated hooks.json byte-stable across rebuilds so Claude Code's hook trust hash stays valid — users do not have to re-review and re-trust hooks on every update. Stale hashed leftovers are pruned automatically. Pass --no-stable-names to restore the pre-1.7 hashed naming.Context detection (plugin vs agent):
The CLI infers whether the build is a plugin or a .claude/-style agent install by inspecting the output path:
.claude-plugin/ directory exists by walking up from the output, it is a plugin build — commands use $CLAUDE_PLUGIN_ROOT..claude/ segment, it is an agent build — commands use "$CLAUDE_PROJECT_DIR"..claude/ segment (fixed in 1.7), so a plugin whose output happens to sit under a path containing .claude/ is still classified correctly.Loader guidance:
SessionStart and SubagentStart preambles:
import preamble from './prompts/session-start.md';
import { sessionStartHook, sessionStartOutput } from '@goodfoot/claude-code-hooks';
export default sessionStartHook({}, () => {
return sessionStartOutput({
hookSpecificOutput: { additionalContext: preamble }
});
});
claude-code-hooks build passes.Here is a complete, working example of a PreToolUse hook. It uses the Factory Pattern (preToolUseHook) and the Output Builder (preToolUseOutput).
Goal: Prevent accidental deletion of the root directory.
// hooks/block-dangerous.ts
import { preToolUseHook, preToolUseOutput } from '@goodfoot/claude-code-hooks';
// 1. Export Default is MANDATORY.
// 2. Factory handles input typing and error wrapping.
// 3. Matcher 'Bash' with typed overload: tool_input is automatically typed as BashToolInput!
export default preToolUseHook({ matcher: 'Bash' }, (input, { logger }) => {
// 4. Input uses wire format (snake_case: tool_input, tool_name).
// 5. With typed overload, tool_input.command is typed as string - no cast needed!
const command = input.tool_input.command;
// 6. Logging uses the context logger, NEVER console.log or console.error.
logger.info('Checking command safety', { command });
if (command.includes('rm -rf /')) {
logger.warn('Blocked dangerous root deletion', { command });
// 7. Return structured output using the builder.
// 8. systemMessage is shown to the user in the UI.
return preToolUseOutput({
systemMessage: 'Safety: Dangerous root deletion command blocked.',
hookSpecificOutput: {
permissionDecision: 'deny',
permissionDecisionReason: 'Safety Policy: Root deletion is forbidden.'
}
});
}
// 9. Default: Allow execution with a status message.
return preToolUseOutput({
systemMessage: 'Command validated by safety policy.'
});
});
Multi-tool hooks with type guards:
For hooks matching multiple tools (e.g., 'Write|Edit|MultiEdit'), use type guards:
import {
preToolUseHook, preToolUseOutput,
isWriteTool, isEditTool, getFilePath, isTsFile, checkContentForPattern
} from '@goodfoot/claude-code-hooks';
export default preToolUseHook({ matcher: 'Write|Edit|MultiEdit' }, (input, { logger }) => {
const filePath = getFilePath(input);
if (!filePath || !isTsFile(filePath)) return preToolUseOutput({});
// Check if problematic patterns are being added
const result = checkContentForPattern(input, /console\.log/g);
if (result?.isAddition) {
return preToolUseOutput({
systemMessage: 'Code quality: console.log statements are not permitted.',
hookSpecificOutput: {
permissionDecision: 'deny',
permissionDecisionReason: `Cannot add console.log: ${result.matches.join(', ')}`
}
});
}
return preToolUseOutput({
systemMessage: 'File modification approved.'
});
});
Use the scaffold command when setting up new packages. This generates a complete TypeScript project with tests, linting, and build scripts.
Scaffold Command:
npx @goodfoot/claude-code-hooks --scaffold /path/to/my-hooks --hooks Stop,SubagentStop -o ./hooks.json
What you get:
src/: Type-safe hook implementations.test/: Vitest tests for your hooks.package.json: Configured with build, test, and lint scripts.tsconfig.json & biome.json: Best-practice configuration.Next Steps:
cd my-hooksnpm installnpm run build (Compiles hooks to the specified output path)npm test (Runs the generated tests)Available Hook Types: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, Notification, UserPromptExpansion, UserPromptSubmit, SessionStart, SessionEnd, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, PermissionRequest, Setup, TeammateIdle, TaskCreated, TaskCompleted, CwdChanged, FileChanged, MessageDisplay
Monorepo? Use -o to output directly to a plugin directory:
npx @goodfoot/claude-code-hooks --scaffold ./packages/my-hooks --hooks PreToolUse,PostToolUse -o ../../plugins/my-plugin/hooks/hooks.json
See Installation: Scaffolding for Monorepos.
Different hooks have different capabilities. This table clarifies what each hook type can do:
| Hook Type | Can Block? | Can Deny? | Can Add Context? | Has Decision Field? |
|---|---|---|---|---|
| PreToolUse | No | Yes (permissionDecision: 'deny') | No | No |
| PostToolUse | No | No | Yes (additionalContext) | No |
| PostToolUseFailure | No | No | Yes (additionalContext) | No |
| PostToolBatch | No | No | Yes (additionalContext) | No |
| Stop | Yes | N/A | No | Yes (decision: 'block') |
| SubagentStop | Yes | N/A | No | Yes (decision: 'block') |
| PermissionRequest | No | Yes (decision.behavior: 'deny') | No | Yes |
| UserPromptExpansion | No | No | Yes (additionalContext) | No |
| UserPromptSubmit | No | No | Yes (additionalContext) | No |
| SessionStart | No | No | Yes (additionalContext) | No |
| SubagentStart | No | No | Yes (additionalContext) | No |
| SessionEnd | No | No | No | No |
| StopFailure | No | No | No | No |
| Notification | No | No | Yes (additionalContext) | No |
| PreCompact | No | No | No | No |
| PostCompact | No | No | No | No |
| Setup | No | No | Yes (additionalContext) | No |
| TeammateIdle | Yes (stderr) | No | No | No |
| TaskCreated | Yes (stderr) | No | No | No |
| TaskCompleted | Yes (stderr) | No | No | No |
| CwdChanged | No | No | No | No |
| FileChanged | No | No | No | No |
| MessageDisplay | No | No | No | No |
Key distinction: Stop and SubagentStop hooks use decision: 'block'. TeammateIdle, TaskCreated, and TaskCompleted hooks use stderr for exit-code-based blocking (no Common Options). CwdChanged and FileChanged hooks return hookSpecificOutput.watchPaths to register/update paths for FileChanged events. MessageDisplay is display-only: return hookSpecificOutput.displayContent to replace the on-screen delta without changing the stored message. Other hooks signal issues through additionalContext, systemMessage, or permissionDecision.
Block ESLint/TypeScript disable comments and type bypasses:
const BYPASS_PATTERNS = [
{ pattern: /\/\/\s*eslint-disable/g, name: 'ESLint disable' },
{ pattern: /\/\/\s*@ts-ignore/g, name: '@ts-ignore' },
{ pattern: /\bas\s+any\b/g, name: 'as any' },
] as const;
export default preToolUseHook({ matcher: 'Write|Edit|MultiEdit' }, (input, { logger }) => {
const filePath = getFilePath(input);
if (!filePath || !isJsTsFile(filePath)) return preToolUseOutput({});
const violations: string[] = [];
for (const { pattern, name } of BYPASS_PATTERNS) {
const result = checkContentForPattern(input, pattern);
if (result?.isAddition) violations.push(name);
}
if (violations.length > 0) {
return preToolUseOutput({
systemMessage: `Code quality: ${violations.length} bypass pattern(s) blocked.`,
hookSpecificOutput: {
permissionDecision: 'deny',
permissionDecisionReason: `Cannot add: ${violations.join(', ')}`
}
});
}
return preToolUseOutput({
systemMessage: 'File passed code quality checks.'
});
});
See Checking Multiple Patterns for the complete example.
Run TypeScript or ESLint after file changes and return errors:
export default postToolUseHook({ matcher: 'Write|Edit|MultiEdit', timeout: 60000 }, (input, { logger }) => {
const filePath = getFilePath(input);
if (!filePath || !isTsFile(filePath)) return postToolUseOutput({});
try {
execSync('tsc --noEmit', { cwd: input.cwd, encoding: 'utf-8', timeout: 30000 });
return postToolUseOutput({
systemMessage: 'TypeScript validation passed.'
});
} catch (error) {
const stderr = (error as { stderr?: string }).stderr ?? '';
return postToolUseOutput({
systemMessage: 'TypeScript errors found. Please fix before proceeding.',
hookSpecificOutput: { additionalContext: `TypeScript errors:\n${stderr}` }
});
}
});
See Run Validation and Report Errors for details.
Standard pattern to skip non-relevant files:
const filePath = getFilePath(input);
if (!filePath || !isTsFile(filePath)) return preToolUseOutput({});
When helping a user with hooks, you MUST follow this protocol:
@goodfoot/claude-code-hooks.npx ... (or npm run build if scaffolded) after every edit..md, .txt, or similar assets, ensure claude-code-hooks --loader ... and the test runner configuration agree.console.log & console.error: Aggressively correct any code using console.log or console.error to use logger. Stdio is reserved for the protocol; direct writes cause silent failures or UI corruption.export default hookFactory(...).Before debugging hook issues, verify:
@goodfoot/claude-code-hooks is in package.json dependenciespackage.json (e.g., "build": "claude-code-hooks -i ...")npm run build)console.log or console.error in hook code (use logger instead)export default hookFactory(...) patternStandalone Project:
Add the absolute path to your ~/.claude/config.json:
{ "hooks": "/absolute/path/to/project/dist/hooks.json" }
Claude Code Plugin (Recommended):
The hooks.json is auto-detected if placed in the plugin root.
Build command: npx -y @goodfoot/claude-code-hooks -i "hooks/src/*.ts" -o "./hooks.json"
Monorepo Project: For hooks in a separate package that output to a plugin directory, see Monorepo Integration. This is the recommended pattern for migrating existing hooks.
getProjectDir, persistEnvVar.npx claudepluginhub goodfoot-io/marketplace --plugin claude-code-hooksCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.