Test Writing Workflow
iOfficeAI/AionUi
Sets the test-writing workflow for the repository: risk-first scenario lists, behavior-focused Vitest tests, a full run before each commit and a coverage target.
Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development…
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install pr-pm/prpm typescript-hook-writer --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .claude/skills/typescript-hook-writer && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "typescript-hook-writer" agent skill from https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writer into .claude/skills/typescript-hook-writer/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "typescript-hook-writer", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writerType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install pr-pm/prpm typescript-hook-writer --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .agents/skills/typescript-hook-writer && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "typescript-hook-writer" agent skill from https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writer into .agents/skills/typescript-hook-writer/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "typescript-hook-writer", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install pr-pm/prpm typescript-hook-writer --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .cursor/skills/typescript-hook-writer && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "typescript-hook-writer" agent skill from https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writer into .cursor/skills/typescript-hook-writer/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "typescript-hook-writer", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/pr-pm/prpm.git --path .claude/skills/typescript-hook-writer--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install pr-pm/prpm typescript-hook-writer --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .gemini/skills/typescript-hook-writer && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "typescript-hook-writer" agent skill from https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writer into .gemini/skills/typescript-hook-writer/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "typescript-hook-writer", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install pr-pm/prpm typescript-hook-writerInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .github/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .github/skills/typescript-hook-writer && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "typescript-hook-writer" agent skill from https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writer into .github/skills/typescript-hook-writer/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "typescript-hook-writer", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add pr-pm/prpm --skill typescript-hook-writer -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install pr-pm/prpm typescript-hook-writer --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/pr-pm/prpm.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.claude/skills/typescript-hook-writer .opencode/skills/typescript-hook-writer && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "typescript-hook-writer" agent skill from https://github.com/pr-pm/prpm/tree/main/.claude/skills/typescript-hook-writer into .opencode/skills/typescript-hook-writer/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "typescript-hook-writer", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
typescript-hook-writerExpert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development…
Typescript Hook Writer is an agent skill from pr-pm/prpm. Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development experience
Its SKILL.md is about 8.6k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Testing & QA, covering Unit testing and Hooks and plugins. It works with TypeScript, Vitest and Bash. The repository describes itself as: The universal registry for AI coding tools. The licence is MIT.
12 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 5f993e6. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
pnpmnpmjqprettiernodeFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
github.comAlso links to:
code.claude.comtypescriptlang.orgesbuild.github.iovitest.devprpm.devFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Typescript Hook Writer loads about 8.6k tokens when it runs. Until then it costs about 57 tokens; SKILL.md has 1,313 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check noted patterns worth knowing about, such as sudo or a known installer.
const blockedPatterns = ['.env', '.env.*', '*.pem', '*.key', '*credentials*'];Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from pr-pm/prpm at commit 5f993e6, republished under its MIT licence (© pr-pm). 1,313 words, ~8,602 tokens.
.claude/skills/typescript-hook-writer/SKILL.md (or your agent's skills folder).Use this skill when developing Claude Code hooks in TypeScript. This skill ensures you maintain type safety, shared utilities, proper build pipeline, and comprehensive testing while distributing single-file JavaScript bundles to users.
Advantages over bash:
Trade-offs:
When to use TypeScript hooks:
When to stick with bash:
packages/hooks/
├── package.json # Build tooling (esbuild, tsx, vitest)
├── tsconfig.json # TypeScript configuration
├── vitest.config.ts # Test configuration
├── scripts/
│ └── build-all-hooks.ts # Build script (compiles all hooks)
├── shared/
│ ├── types.ts # Shared TypeScript interfaces
│ ├── hook-utils.ts # Shared utility functions
│ └── hook-utils.test.ts # Tests for shared utilities
# Each hook lives in .claude/hooks/
.claude/hooks/
├── my-hook/
│ ├── hook.json # Hook configuration
│ ├── README.md # Documentation
│ ├── src/
│ │ ├── hook.ts # TypeScript source (development)
│ │ ├── hook.test.ts # Vitest tests
│ │ ├── hook-utils.ts # Copied shared utilities
│ │ └── types.ts # Copied shared types
│ └── dist/
│ └── hook.js # Compiled bundle (distributed){
"name": "@prpm/hooks",
"version": "1.0.0",
"description": "Build system and shared utilities for PRPM Claude Code hooks",
"private": true,
"type": "module",
"scripts": {
"build": "tsx scripts/build-all-hooks.ts",
"build:watch": "tsx scripts/build-all-hooks.ts --watch",
"test": "vitest",
"test:watch": "vitest --watch",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@types/node": "^20.10.0",
"esbuild": "^0.19.8",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"vitest": "^1.0.4"
}
}{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/scripts/**',
],
},
},
});#!/usr/bin/env tsx
/**
* Build script for compiling all Claude Code hooks to standalone JavaScript bundles
*/
import { build, BuildOptions } from 'esbuild';
import { readdirSync, statSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Root directory is app/ which is 3 levels up from packages/hooks/scripts/
const ROOT_DIR = join(__dirname, '../../..');
const HOOKS_DIR = join(ROOT_DIR, '.claude/hooks');
interface HookInfo {
name: string;
srcPath: string;
distPath: string;
}
/**
* Find all hooks with TypeScript source files
*/
function findHooks(): HookInfo[] {
const hooks: HookInfo[] = [];
if (!existsSync(HOOKS_DIR)) {
console.error(`Hooks directory not found: ${HOOKS_DIR}`);
return hooks;
}
const entries = readdirSync(HOOKS_DIR);
for (const entry of entries) {
const hookPath = join(HOOKS_DIR, entry);
// Skip non-directories and special directories
if (!statSync(hookPath).isDirectory() || entry === 'shared') {
continue;
}
const srcPath = join(hookPath, 'src/hook.ts');
const distPath = join(hookPath, 'dist/hook.js');
if (existsSync(srcPath)) {
hooks.push({
name: entry,
srcPath,
distPath,
});
}
}
return hooks;
}
/**
* Build a single hook
*/
async function buildHook(hook: HookInfo): Promise<void> {
const buildOptions: BuildOptions = {
entryPoints: [hook.srcPath],
bundle: true,
platform: 'node',
target: 'node18',
outfile: hook.distPath,
format: 'cjs',
minify: false, // Keep readable for debugging
sourcemap: false,
logLevel: 'error',
banner: {
js: '#!/usr/bin/env node',
},
};
try {
// Ensure dist directory exists
const distDir = dirname(hook.distPath);
if (!existsSync(distDir)) {
mkdirSync(distDir, { recursive: true });
}
await build(buildOptions);
console.log(`✓ Built ${hook.name}`);
// Make the output file executable
const { chmodSync } = await import('fs');
chmodSync(hook.distPath, 0o755);
} catch (error) {
console.error(`✗ Failed to build ${hook.name}:`, error);
throw error;
}
}
/**
* Build all hooks
*/
async function buildAll(watch: boolean = false): Promise<void> {
console.log('🔨 Building PRPM Claude Code hooks...\n');
const hooks = findHooks();
if (hooks.length === 0) {
console.log('No hooks found to build.');
return;
}
console.log(`Found ${hooks.length} hooks:\n`);
// Build all hooks in parallel
try {
await Promise.all(hooks.map(hook => buildHook(hook)));
console.log(`\n✓ Built ${hooks.length} hooks successfully`);
} catch (error) {
console.error('\n✗ Build failed');
process.exit(1);
}
if (watch) {
console.log('\n👀 Watching for changes...');
console.log('⚠️ Watch mode not yet implemented. Run `npm run build` after changes.');
}
}
// Parse command line args
const args = process.argv.slice(2);
const watch = args.includes('--watch') || args.includes('-w');
// Run build
buildAll(watch).catch(error => {
console.error('Build error:', error);
process.exit(1);
});Each hook gets its own copy of shared utilities rather than importing from a shared package:
Benefits:
Trade-off:
Define TypeScript interfaces for hook input, exit codes, and options:
/**
* Shared TypeScript types for Claude Code hooks
*/
export interface HookInput {
session_id?: string;
transcript_path?: string;
current_dir?: string;
input?: {
file_path?: string;
command?: string;
content?: string;
old_string?: string;
new_string?: string;
[key: string]: any;
};
message?: string;
tool?: string;
[key: string]: any;
}
export enum HookExitCode {
Success = 0, // Continue operation
Error = 1, // Log error but continue
Block = 2, // Block operation (PreToolUse only)
}
export interface ExecOptions {
skipOnMissing?: boolean; // Exit successfully if command not found
background?: boolean; // Run in background (don't wait)
timeout?: number; // Timeout in milliseconds
env?: Record<string, string>; // Environment variables
}
export interface PatternMatch {
matched: boolean;
pattern?: string;
}Common utility functions used across hooks:
import { readFileSync, appendFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import type { HookInput, HookExitCode, ExecOptions, PatternMatch } from './types';
/**
* Read and parse JSON from stdin
*/
export function readStdin(): HookInput {
try {
const input = readFileSync(0, 'utf-8');
return JSON.parse(input);
} catch (error) {
return {};
}
}
/**
* Extract file path from hook input
*/
export function getFilePath(input: HookInput): string | undefined {
return input.input?.file_path;
}
/**
* Extract command from hook input
*/
export function getCommand(input: HookInput): string | undefined {
return input.input?.command;
}
/**
* Extract content from hook input
*/
export function getContent(input: HookInput): string | undefined {
return input.input?.content || input.input?.new_string;
}
/**
* Check if file has one of the specified extensions
*/
export function hasExtension(filePath: string, extensions: string[]): boolean {
return extensions.some(ext => filePath.endsWith(ext));
}
/**
* Check if a command exists in PATH
*/
export function commandExists(command: string): boolean {
try {
execSync(`command -v ${command}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* Execute a command with options
*/
export function execCommand(
command: string,
args: string[],
options: ExecOptions = {}
): void {
// Check if command exists if skipOnMissing is true
if (options.skipOnMissing && !commandExists(command)) {
return;
}
const fullCommand = `${command} ${args.map(arg => `"${arg}"`).join(' ')}`;
if (options.background) {
// Run in background - don't wait for completion
execSync(`(${fullCommand} &)`, {
stdio: 'ignore',
timeout: options.timeout,
env: { ...process.env, ...options.env },
});
} else {
// Run synchronously
execSync(fullCommand, {
stdio: 'inherit',
timeout: options.timeout,
env: { ...process.env, ...options.env },
});
}
}
/**
* Match file path against glob patterns
*/
export function matchesPattern(filePath: string, patterns: string[]): PatternMatch {
for (const pattern of patterns) {
// Convert glob pattern to regex
const regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
if (new RegExp(`^${regexPattern}$`).test(filePath)) {
return { matched: true, pattern };
}
}
return { matched: false };
}
/**
* Append line to log file
*/
export function appendToLog(logFile: string, line: string): void {
try {
appendFileSync(logFile, line + '\n', 'utf-8');
} catch {
// Fail silently
}
}
/**
* Get current timestamp in YYYY-MM-DD HH:MM:SS format
*/
export function getTimestamp(): string {
return new Date().toISOString().replace('T', ' ').substring(0, 19);
}
/**
* Log error message to stderr
*/
export function logError(message: string): void {
console.error(message);
}
/**
* Log warning message to stderr
*/
export function logWarning(message: string): void {
console.error(message);
}
/**
* Exit hook with specified code
*/
export function exitHook(code: HookExitCode): never {
process.exit(code);
}
// Re-export HookExitCode for convenience
export { HookExitCode } from './types';mkdir -p .claude/hooks/my-hook/src
mkdir -p .claude/hooks/my-hook/distCopy shared/types.ts and shared/hook-utils.ts into the hook's src/ directory:
cp packages/hooks/shared/types.ts .claude/hooks/my-hook/src/
cp packages/hooks/shared/hook-utils.ts .claude/hooks/my-hook/src/Why copy instead of symlink? Each hook becomes a standalone bundle when compiled. The build process bundles utilities into the final .js file.
.claude/hooks/my-hook/src/hook.ts:
#!/usr/bin/env tsx
/**
* My Hook
* Description of what this hook does
*/
import {
readStdin,
getFilePath,
hasExtension,
execCommand,
logError,
logWarning,
exitHook,
HookExitCode,
} from './hook-utils';
async function main() {
// Read input from stdin
const input = readStdin();
// Extract file path
const filePath = getFilePath(input);
if (!filePath) {
exitHook(HookExitCode.Success);
}
// Validate file extension
const supportedExtensions = ['.ts', '.tsx', '.js', '.jsx'];
if (!hasExtension(filePath, supportedExtensions)) {
exitHook(HookExitCode.Success);
}
// Perform hook action
try {
execCommand('prettier', ['--write', filePath], {
skipOnMissing: true,
background: true,
});
exitHook(HookExitCode.Success);
} catch (error) {
logError(`Failed to format ${filePath}: ${error}`);
exitHook(HookExitCode.Error);
}
}
main().catch(() => {
exitHook(HookExitCode.Success); // Don't block on errors
});.claude/hooks/my-hook/hook.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/my-hook/dist/hook.js",
"timeout": 5000
}
]
}
]
}
}Important: Reference dist/hook.js (compiled), not src/hook.ts (source).
All hook types support optional fields for controlling execution behavior:
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "node .claude/hooks/my-hook/dist/hook.js",
"timeout": 5000,
"continue": true, // Whether Claude continues after hook (default: true)
"stopReason": "string", // Message shown when continue is false
"suppressOutput": false, // Hide stdout from transcript (default: false)
"systemMessage": "string" // Warning message shown to user
}]
}]
}
}continue (boolean, default: true)Controls whether Claude continues after hook execution.
When to use false:
{
"type": "command",
"command": "node .claude/hooks/security-validator/dist/hook.js",
"continue": false,
"stopReason": "Security validation failed. Please review the detected issues before proceeding."
}Exit code interaction:
HookExitCode.Block (2): continue is ignored, operation is blockedHookExitCode.Success (0) or HookExitCode.Error (1): continue field determines behaviorstopReason (string)Message displayed to user when continue: false. Should explain why execution stopped and what action is needed.
{
"continue": false,
"stopReason": "Pre-commit checks failed. Fix linting errors and try again."
}suppressOutput (boolean, default: false)Hides hook stdout from transcript mode (Ctrl-R). Stderr is always shown.
When to use true:
{
"type": "command",
"command": "node .claude/hooks/cloud-sync/dist/hook.js",
"suppressOutput": true // Don't show sync progress in transcript
}Note: Always show critical errors via stderr (use logError()), as stderr is never suppressed.
systemMessage (string)Warning or info message shown to user when hook executes. Useful for non-blocking warnings.
TypeScript example:
// In your hook.ts
if (outdatedDeps.length > 0) {
logWarning(`Found ${outdatedDeps.length} outdated dependencies`);
// systemMessage in hook.json will also show to user
}{
"type": "command",
"command": "node .claude/hooks/dependency-checker/dist/hook.js",
"systemMessage": "⚠️ Some dependencies are outdated. Consider running 'npm update'."
}Difference from stopReason:
systemMessage: Informational, Claude continuesstopReason: Critical, requires continue: falsecd packages/hooks
pnpm buildOutput:
🔨 Building PRPM Claude Code hooks...
Found 1 hooks:
✓ Built my-hook
✓ Built 1 hooks successfullyThe compiled hook is now at .claude/hooks/my-hook/dist/hook.js (~2-3KB single file).
.claude/hooks/my-hook/src/hook.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the hook-utils module
vi.mock('./hook-utils', () => ({
readStdin: vi.fn(),
getFilePath: vi.fn(),
hasExtension: vi.fn(),
execCommand: vi.fn(),
logError: vi.fn(),
exitHook: vi.fn((code: number) => {
throw new Error(`EXIT_${code}`);
}),
HookExitCode: {
Success: 0,
Error: 1,
Block: 2,
},
}));
describe('my-hook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should exit successfully if no file path is provided', async () => {
const { readStdin, getFilePath, exitHook, HookExitCode } = await import('./hook-utils');
vi.mocked(readStdin).mockReturnValue({});
vi.mocked(getFilePath).mockReturnValue(undefined);
try {
await import('./hook');
} catch (error: any) {
expect(error.message).toBe('EXIT_0');
}
expect(exitHook).toHaveBeenCalledWith(HookExitCode.Success);
});
it('should format supported file types', async () => {
const { readStdin, getFilePath, hasExtension, execCommand, exitHook, HookExitCode } = await import('./hook-utils');
vi.mocked(readStdin).mockReturnValue({
input: { file_path: '/path/to/file.ts' },
});
vi.mocked(getFilePath).mockReturnValue('/path/to/file.ts');
vi.mocked(hasExtension).mockReturnValue(true);
try {
await import('./hook');
} catch (error: any) {
expect(error.message).toBe('EXIT_0');
}
expect(execCommand).toHaveBeenCalledWith(
'prettier',
['--write', '/path/to/file.ts'],
expect.objectContaining({ background: true })
);
});
it('should skip unsupported file types', async () => {
const { readStdin, getFilePath, hasExtension, execCommand, exitHook } = await import('./hook-utils');
vi.mocked(readStdin).mockReturnValue({
input: { file_path: '/path/to/file.py' },
});
vi.mocked(getFilePath).mockReturnValue('/path/to/file.py');
vi.mocked(hasExtension).mockReturnValue(false);
try {
await import('./hook');
} catch (error: any) {
// Expected exit
}
expect(execCommand).not.toHaveBeenCalled();
});
});cd packages/hooks
pnpm test:runOutput:
✓ .claude/hooks/my-hook/src/hook.test.ts (3 tests) 5ms
Test Files 1 passed (1)
Tests 3 passed (3)
Duration 182mspnpm test:coverageCoverage report shows which code paths are tested.
Development files (NOT distributed):
.claude/hooks/my-hook/src/hook.ts - TypeScript source.claude/hooks/my-hook/src/hook.test.ts - Tests.claude/hooks/my-hook/src/types.ts - Type definitions.claude/hooks/my-hook/src/hook-utils.ts - UtilitiesDistribution files (what users get):
.claude/hooks/my-hook/dist/hook.js - Compiled JavaScript bundle (~2-3KB).claude/hooks/my-hook/hook.json - Hook configuration.claude/hooks/my-hook/README.md - DocumentationThe build script (packages/hooks/scripts/build-all-hooks.ts) does the following:
.claude/hooks/ for directories with src/hook.ts#!/usr/bin/env node shebangdist/hook.js in each hook directorychmod +x)Automatic build:
prpm publish - prepublishOnly script builds automatically (if configured)Manual build for:
How to build manually:
# Build all hooks once
cd packages/hooks
pnpm build
# Output:
# 🔨 Building PRPM Claude Code hooks...
# Found 7 hooks:
# ✓ Built prettier-on-save
# ✓ Built command-logger
# ...
# ✓ Built 7 hooks successfullyBuild output structure:
.claude/hooks/my-hook/
├── src/
│ ├── hook.ts ← TypeScript source (input)
│ ├── hook-utils.ts
│ └── types.ts
└── dist/
└── hook.js ← Compiled JavaScript (output)What gets bundled:
esbuild traces all imports and bundles them into a single dist/hook.js:
// src/hook.ts imports these
import { readStdin, getFilePath } from './hook-utils';
import { HookExitCode } from './types';
// dist/hook.js contains:
// - All code from hook.ts
// - All code from hook-utils.ts
// - All type definitions (compiled to runtime checks)
// - No external dependencies
// - Total size: ~2-3KBWhy this approach:
IMPORTANT: Always build before updating prpm.json or publishing:
cd packages/hooks
pnpm buildVerify dist files exist:
ls -lh .claude/hooks/*/dist/hook.js
# Should show compiled hooks with ~2-3KB size eachAdd the hook to the root prpm.json:
{
"packages": [
{
"name": "my-hook",
"version": "1.0.0",
"description": "Brief description of what the hook does",
"format": "claude",
"subtype": "hook",
"tags": ["formatting", "automation", "typescript"],
"files": [
".claude/hooks/my-hook/hook.json",
".claude/hooks/my-hook/dist/hook.js",
".claude/hooks/my-hook/README.md"
]
}
]
}Important files array:
hook.json - Hook configurationdist/hook.js - Compiled JavaScript (NOT src/hook.ts)README.md - DocumentationDo NOT include:
src/ directory (source code)*.test.ts filesnode_modules/.claude/hooks/my-hook/README.md:
# My Hook
Brief description of what this hook does.
## What It Does
- Automatically formats code after Claude edits files
- Supports TypeScript, JavaScript, JSON, and Markdown
- Runs in background (non-blocking)
- Gracefully skips if Prettier not installed
## Installation
```bash
prpm install @prpm/my-hooknpm install -g prettierThis hook activates on PostToolUse for Edit and Write tools.
To customize supported file extensions, fork and modify the source.
When Claude writes a TypeScript file:
Claude: I'll create a new component...
[Hook auto-formats component.tsx with Prettier]Hook not running?
.claude/settings.json includes the hookdist/hook.js exists and is executableFormat not applying?
prettier --version.prettierrc)
### Step 3: Set Up Automatic Build Before Publishing
**Good news:** PRPM now supports `prepublishOnly` scripts! Add this to your prpm.json:
```json
{
"name": "prpm-packages",
"license": "MIT",
"repository": "https://github.com/username/repo",
"scripts": {
"prepublishOnly": "cd packages/hooks && npm run build"
},
"packages": [
// ... your packages
]
}What happens:
prpm publish, the prepublishOnly script runs automaticallyManual build (for local testing):
cd packages/hooks
npm run buildVerify dist/hook.js files exist:
ls -lh .claude/hooks/*/dist/hook.js
# Should show compiled hooks with ~2-3KB size each# From project root
prpm publishWhat happens automatically:
prepublishOnly script runs: cd packages/hooks && npm run buildUsers will receive:
hook.json - Configurationdist/hook.js - Single-file executable (~2-3KB)README.md - DocumentationUsers do NOT get:
Why this matters: The prepublishOnly script prevents publishing stale dist files. If you modify a hook's TypeScript source but forget to build, the build happens automatically before publish.
Target < 100ms execution time. Use background execution for slow operations:
// BAD - blocks for 5 seconds
execCommand('npm', ['test']);
// GOOD - runs in background
execCommand('npm', ['test'], { background: true });Never crash. Handle missing tools:
execCommand('prettier', ['--write', filePath], {
skipOnMissing: true, // Exit successfully if prettier not found
background: true,
});Validate input shape:
function isValidInput(input: HookInput): boolean {
return !!(input.input?.file_path && typeof input.input.file_path === 'string');
}
if (!isValidInput(input)) {
exitHook(HookExitCode.Success);
}Always copy (not import) shared utilities into each hook's src/ directory:
cp packages/hooks/shared/{types.ts,hook-utils.ts} .claude/hooks/my-hook/src/This ensures standalone compilation.
Test with edge cases:
it('should handle files with spaces', async () => {
const input = { input: { file_path: '/path/my file.ts' } };
// ...
});
it('should handle Unicode filenames', async () => {
const input = { input: { file_path: '/path/文件.ts' } };
// ...
});
it('should handle missing input fields', async () => {
const input = { input: {} };
// ...
});// Success - continue operation
exitHook(HookExitCode.Success);
// Block - prevent operation (PreToolUse only)
exitHook(HookExitCode.Block);
// Error - log but continue
exitHook(HookExitCode.Error);// WRONG - pollutes transcript
console.log('Processing file...');
// RIGHT - logs to stderr
logError('⚠️ Warning: something happened');
logWarning('ℹ️ Info: skipping file');const supportedExtensions = ['.ts', '.tsx', '.js', '.jsx'];
if (!hasExtension(filePath, supportedExtensions)) {
exitHook(HookExitCode.Success);
}const blockedPatterns = ['.env', '.env.*', '*.pem', '*.key', '*credentials*'];
const match = matchesPattern(filePath, blockedPatterns);
if (match.matched) {
logError(`⛔ Blocked: Cannot modify sensitive file '${filePath}'`);
logError(` Pattern: ${match.pattern}`);
exitHook(HookExitCode.Block);
}import { join } from 'path';
import { homedir } from 'os';
const command = getCommand(input);
if (!command) {
exitHook(HookExitCode.Success);
}
const logFile = join(homedir(), '.claude-commands.log');
const logLine = `[${getTimestamp()}] ${command}`;
appendToLog(logFile, logLine);
exitHook(HookExitCode.Success);const content = getContent(input);
if (!content) {
exitHook(HookExitCode.Success);
}
const dangerousPatterns = [
/password\s*=\s*["'][^"']+["']/i,
/api[_-]?key\s*=\s*["'][^"']+["']/i,
/AKIA[0-9A-Z]{16}/, // AWS access key
];
for (const pattern of dangerousPatterns) {
if (pattern.test(content)) {
logWarning(`⚠️ Warning: Potential credential detected in ${filePath}`);
logWarning(` Pattern matched: ${pattern}`);
break;
}
}
exitHook(HookExitCode.Success);cd packages/hooks
pnpm buildCheck for TypeScript errors.
echo '{"input":{"file_path":"/tmp/test.ts"}}' | node .claude/hooks/my-hook/dist/hook.js
echo $? # Check exit codeVerify hook appears in .claude/settings.json:
cat .claude/settings.json | jq '.hooks'Run Claude Code and check transcript (Ctrl-R) for hook execution:
PostToolUse hook: my-hook
command: node .claude/hooks/my-hook/dist/hook.js
exit: 0
duration: 47msTemporarily add debug output:
logError(`[DEBUG] Processing file: ${filePath}`);
logError(`[DEBUG] Extensions: ${JSON.stringify(supportedExtensions)}`);Converting an existing bash hook to TypeScript:
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
if [[ -z "$FILE" ]]; then
exit 0
fi
if ! command -v prettier &>/dev/null; then
exit 0
fi
prettier --write "$FILE" &
exit 0#!/usr/bin/env tsx
import {
readStdin,
getFilePath,
execCommand,
exitHook,
HookExitCode,
} from './hook-utils';
async function main() {
const input = readStdin();
const filePath = getFilePath(input);
if (!filePath) {
exitHook(HookExitCode.Success);
}
execCommand('prettier', ['--write', filePath], {
skipOnMissing: true,
background: true,
});
exitHook(HookExitCode.Success);
}
main().catch(() => exitHook(HookExitCode.Success));Benefits:
input objectpnpm build # Build all hooks once
pnpm build:watch # Watch mode (not yet implemented)
pnpm test # Run tests in watch mode
pnpm test:run # Run tests once
pnpm test:coverage # Run tests with coverageRequired:
type - "command" or "prompt"command - Path to compiled hook (e.g., "node .claude/hooks/my-hook/dist/hook.js")Optional:
timeout - Max execution time in ms (default: 60000)continue - Continue after hook? (default: true)stopReason - Message when continue=falsesuppressOutput - Hide stdout from transcript (default: false)systemMessage - Warning message to userHookExitCode.Success = 0 // Continue operation
HookExitCode.Error = 1 // Log error but continue
HookExitCode.Block = 2 // Block operation (PreToolUse only).claude/hooks/my-hook/src/hook.tstypes.ts and hook-utils.ts to src/hook.json referencing dist/hook.jsREADME.md with installation and usagehook.test.ts with test coveragepnpm builddist/hook.js exists and is executableprpm.json with correct files arrayInclude:
.claude/hooks/my-hook/hook.json.claude/hooks/my-hook/dist/hook.js.claude/hooks/my-hook/README.mdExclude:
src/ directory*.test.ts filesnode_modules/readStdin() // Parse stdin JSON
getFilePath(input) // Extract file path
getCommand(input) // Extract command
getContent(input) // Extract content
hasExtension(path, ['.ts', '.js']) // Check extension
matchesPattern(path, ['*.env']) // Glob matching
commandExists('prettier') // Check command exists
execCommand('cmd', ['arg'], opts) // Execute command
appendToLog(file, line) // Append to log
getTimestamp() // Current timestamp
logError(msg) // Log to stderr
logWarning(msg) // Log warning
exitHook(HookExitCode.Success) // Exit with code© pr-pm, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in .claude/skills/typescript-hook-writer of pr-pm/prpm.
Open the folder on GitHubat commit 5f993e6
Typescript Hook Writer next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Typescript Hook Writer this skillpr-pm/prpm | 122 | — | ~8.6k | Automated safety check: Notes | MIT | |
| Test Writing WorkflowiOfficeAI/AionUi | 33k | 1 repos | ~1.2k | Automated safety check: Pass | Apache-2.0 | |
| Ckeditor5 TestingTriliumNext/Trilium | 38k | — | ~3.3k | Automated safety check: Pass | AGPL-3.0 | |
| Creating A Packagec15t/c15t | 1.9k | — | ~913 | Automated safety check: Pass | Apache-2.0 | |
| Svelte Testingspences10/sveltest | 113 | — | ~579 | Automated safety check: Pass | MIT | |
| Effect TStellahq/opensession | 392 | — | ~3.7k | Automated safety check: Pass | MIT |
iOfficeAI/AionUi
Sets the test-writing workflow for the repository: risk-first scenario lists, behavior-focused Vitest tests, a full run before each commit and a coverage target.
TriliumNext/Trilium
Testing CKEditor 5 plugins in the Trilium monorepo. An agent skill from TriliumNext/Trilium.
c15t/c15t
Scaffold a new workspace package in the c15t monorepo. An agent skill from c15t/c15t.
spences10/sveltest
Fix and create Svelte 5 tests with vitest-browser-svelte and Playwright.
tellahq/opensession
Write idiomatic Effect v4 TypeScript verified against the pinned effect@4.0.0-rc.112 source.
home-assistant/frontend
Home Assistant frontend testing and validation workflow. An agent skill from home-assistant/frontend.
pr-pm/prpm
Reference for writing Claude Code agent files: location, frontmatter fields, validation limits, tool and model choices, and the required content format.
pr-pm/prpm
Covers how to build, configure and publish Claude Code hooks: event types, exit codes, JSON I/O, and PRPM packaging.
pr-pm/prpm
Shows how to write .claude/rules/ files correctly: paths frontmatter instead of globs, quoted glob patterns, global rules and conversion of Cursor rules.
pr-pm/prpm
Reference for writing portable Agent Skills packages, covering SKILL.md frontmatter limits, name rules, directory layout and where Codex CLI, GitHub Copilot and Amp look for skills.
pr-pm/prpm
A skill your agent uses when building custom Kiro AI agents or when user asks for agent configurations - provides JSON structure, tool configuration, prompt patterns, and security best practices for…
pr-pm/prpm
A skill your agent uses when creating OpenCode agents - provides markdown format with YAML frontmatter, mode/tools/permission configuration, and best practices for specialized AI assistants
Works with
Categories
Expert guidance for developing Claude Code hooks in TypeScript with shared utilities, esbuild compilation, and Vitest testing - distributes compiled JS while maintaining TypeScript development…. Typescript Hook Writer is an agent skill from pr-pm/prpm.
Typescript Hook Writer fits situations like: tasks that involve Unit testing; tasks that involve Hooks and plugins.
Run `npx skills add pr-pm/prpm --skill typescript-hook-writer -a claude-code`. Or copy the skill folder (.claude/skills/typescript-hook-writer in pr-pm/prpm) into .claude/skills/typescript-hook-writer in your project. Claude Code loads it when a task matches its description.
Run `npx skills add pr-pm/prpm --skill typescript-hook-writer -a codex`. Or copy the skill folder (.claude/skills/typescript-hook-writer in pr-pm/prpm) into .agents/skills/typescript-hook-writer in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add pr-pm/prpm --skill typescript-hook-writer -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/typescript-hook-writer, .gemini/skills/typescript-hook-writer, .github/skills/typescript-hook-writer and .opencode/skills/typescript-hook-writer in your project.
Going by SKILL.md and its folder, Typescript Hook Writer needs the command-line tools its instructions call (pnpm, npm, jq, prettier and node). Our summary lists: Node.js.
SKILL.md names 6 domains. In commands or code: github.com; the agent is likely to contact it when it follows the instructions. As links in the text: code.claude.com, typescriptlang.org, esbuild.github.io, vitest.dev and prpm.dev. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found notes only (mentions a .env file), nothing it rates as a warning. It is not a guarantee. Review the folder before installing.
Typescript Hook Writer is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 8.6k tokens (SKILL.md is roughly 34k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Typescript Hook Writer: Test Writing Workflow (iOfficeAI/AionUi, 33k stars), Ckeditor5 Testing (TriliumNext/Trilium, 38k stars), Creating A Package (c15t/c15t, 1.9k stars) and Svelte Testing (spences10/sveltest, 113 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
pr-pm (a GitHub organization) maintains it in pr-pm/prpm, which has 122 GitHub stars. The repository holds 35 skills in this directory. The repository was last updated on October 6, 2026.
Source: pr-pm/prpm on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.