Initial commit
This commit is contained in:
17
hooks/hooks-app/.eslintrc.js
Normal file
17
hooks/hooks-app/.eslintrc.js
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.eslint.json'
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended'
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
||||
}
|
||||
};
|
||||
7
hooks/hooks-app/.prettierrc
Normal file
7
hooks/hooks-app/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
57
hooks/hooks-app/__tests__/action-handler.test.ts
Normal file
57
hooks/hooks-app/__tests__/action-handler.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// plugin/hooks/hooks-app/__tests__/action-handler.test.ts
|
||||
import { handleAction } from '../src/action-handler';
|
||||
import { GateResult, GatesConfig } from '../src/types';
|
||||
|
||||
const mockConfig: GatesConfig = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
'next-gate': { command: 'echo "next"', on_pass: 'CONTINUE' }
|
||||
}
|
||||
};
|
||||
|
||||
const mockInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test'
|
||||
};
|
||||
|
||||
describe('Action Handler', () => {
|
||||
test('CONTINUE returns continue=true', async () => {
|
||||
const result: GateResult = {};
|
||||
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(true);
|
||||
expect(action.context).toBeUndefined();
|
||||
});
|
||||
|
||||
test('CONTINUE with context returns context', async () => {
|
||||
const result: GateResult = { additionalContext: 'test context' };
|
||||
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(true);
|
||||
expect(action.context).toBe('test context');
|
||||
});
|
||||
|
||||
test('BLOCK returns continue=false', async () => {
|
||||
const result: GateResult = { decision: 'block', reason: 'test reason' };
|
||||
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(false);
|
||||
expect(action.blockReason).toBe('test reason');
|
||||
});
|
||||
|
||||
test('BLOCK with no reason uses default', async () => {
|
||||
const result: GateResult = {};
|
||||
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(false);
|
||||
expect(action.blockReason).toBe('Gate failed');
|
||||
});
|
||||
|
||||
test('STOP returns continue=false with stop message', async () => {
|
||||
const result: GateResult = { message: 'stop message' };
|
||||
const action = await handleAction('STOP', result, mockConfig, mockInput);
|
||||
|
||||
expect(action.continue).toBe(false);
|
||||
expect(action.stopMessage).toBe('stop message');
|
||||
});
|
||||
});
|
||||
33
hooks/hooks-app/__tests__/builtin-gates.test.ts
Normal file
33
hooks/hooks-app/__tests__/builtin-gates.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// plugin/hooks/hooks-app/__tests__/builtin-gates.test.ts
|
||||
import { executeBuiltinGate } from '../src/gate-loader';
|
||||
import { HookInput } from '../src/types';
|
||||
import * as path from 'path';
|
||||
|
||||
// Set CLAUDE_PLUGIN_ROOT for tests to point to plugin directory
|
||||
process.env.CLAUDE_PLUGIN_ROOT = path.resolve(__dirname, '../../..');
|
||||
|
||||
describe('Built-in Gates', () => {
|
||||
describe('plugin-path', () => {
|
||||
test('logs plugin path when available', async () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SessionStart',
|
||||
cwd: '/test'
|
||||
};
|
||||
|
||||
const result = await executeBuiltinGate('plugin-path', input);
|
||||
// plugin-path gate should always continue
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
test('handles SubagentStop hook', async () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
const result = await executeBuiltinGate('plugin-path', input);
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
226
hooks/hooks-app/__tests__/cli.integration.test.ts
Normal file
226
hooks/hooks-app/__tests__/cli.integration.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// __tests__/cli.integration.test.ts
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('CLI Integration', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory for each test
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Session Management Mode', () => {
|
||||
const runCLI = (
|
||||
args: string[]
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('node', ['dist/cli.js', ...args], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
test('should set and get active_command', async () => {
|
||||
// Set
|
||||
const setResult = await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
|
||||
expect(setResult.exitCode).toBe(0);
|
||||
|
||||
// Get
|
||||
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
|
||||
expect(getResult.exitCode).toBe(0);
|
||||
expect(getResult.stdout.trim()).toBe('/execute');
|
||||
});
|
||||
|
||||
test('should set and get active_skill', async () => {
|
||||
// Set
|
||||
const setResult = await runCLI(['session', 'set', 'active_skill', 'brainstorming', testDir]);
|
||||
expect(setResult.exitCode).toBe(0);
|
||||
|
||||
// Get
|
||||
const getResult = await runCLI(['session', 'get', 'active_skill', testDir]);
|
||||
expect(getResult.exitCode).toBe(0);
|
||||
expect(getResult.stdout.trim()).toBe('brainstorming');
|
||||
});
|
||||
|
||||
test('should append to edited_files', async () => {
|
||||
// Append
|
||||
const append1 = await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
|
||||
expect(append1.exitCode).toBe(0);
|
||||
|
||||
const append2 = await runCLI(['session', 'append', 'edited_files', 'file2.ts', testDir]);
|
||||
expect(append2.exitCode).toBe(0);
|
||||
|
||||
// Check contains
|
||||
const contains1 = await runCLI(['session', 'contains', 'edited_files', 'file1.ts', testDir]);
|
||||
expect(contains1.exitCode).toBe(0);
|
||||
|
||||
const contains2 = await runCLI(['session', 'contains', 'edited_files', 'file2.ts', testDir]);
|
||||
expect(contains2.exitCode).toBe(0);
|
||||
|
||||
const notContains = await runCLI([
|
||||
'session',
|
||||
'contains',
|
||||
'edited_files',
|
||||
'file3.ts',
|
||||
testDir
|
||||
]);
|
||||
expect(notContains.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('should append to file_extensions', async () => {
|
||||
// Append
|
||||
const append1 = await runCLI(['session', 'append', 'file_extensions', 'ts', testDir]);
|
||||
expect(append1.exitCode).toBe(0);
|
||||
|
||||
const append2 = await runCLI(['session', 'append', 'file_extensions', 'js', testDir]);
|
||||
expect(append2.exitCode).toBe(0);
|
||||
|
||||
// Check contains
|
||||
const contains1 = await runCLI(['session', 'contains', 'file_extensions', 'ts', testDir]);
|
||||
expect(contains1.exitCode).toBe(0);
|
||||
|
||||
const notContains = await runCLI(['session', 'contains', 'file_extensions', 'py', testDir]);
|
||||
expect(notContains.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('should clear session', async () => {
|
||||
// Set some data
|
||||
await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
|
||||
await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
|
||||
|
||||
// Clear
|
||||
const clearResult = await runCLI(['session', 'clear', testDir]);
|
||||
expect(clearResult.exitCode).toBe(0);
|
||||
|
||||
// Verify cleared
|
||||
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
|
||||
expect(getResult.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
test('should reject invalid session keys', async () => {
|
||||
const result = await runCLI(['session', 'get', 'invalid_key', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Invalid session key: invalid_key');
|
||||
});
|
||||
|
||||
test('should reject invalid array keys for append', async () => {
|
||||
const result = await runCLI(['session', 'append', 'session_id', 'value', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Invalid array key');
|
||||
});
|
||||
|
||||
test('should reject setting non-settable keys', async () => {
|
||||
const result = await runCLI(['session', 'set', 'session_id', 'value', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Cannot set session_id');
|
||||
});
|
||||
|
||||
test('should show usage for missing arguments', async () => {
|
||||
const result = await runCLI(['session']);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Usage:');
|
||||
});
|
||||
|
||||
test('should show error for unknown session command', async () => {
|
||||
const result = await runCLI(['session', 'unknown', testDir]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Unknown session command');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Dispatch Mode', () => {
|
||||
test('should handle hook dispatch with valid JSON input', (done) => {
|
||||
const proc = spawn('node', ['dist/cli.js'], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
const input = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: testDir,
|
||||
tool_name: 'Edit',
|
||||
tool_input: {}
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
expect(code).toBe(0);
|
||||
// Should produce empty output or valid JSON
|
||||
if (stdout.trim()) {
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
});
|
||||
|
||||
test('should handle graceful exit on missing required fields', (done) => {
|
||||
const proc = spawn('node', ['dist/cli.js'], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
const input = JSON.stringify({
|
||||
// Missing hook_event_name and cwd
|
||||
tool_name: 'Edit'
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
expect(code).toBe(0); // Graceful exit
|
||||
done();
|
||||
});
|
||||
|
||||
proc.stdin.write(input);
|
||||
proc.stdin.end();
|
||||
});
|
||||
|
||||
test('should handle invalid JSON input', (done) => {
|
||||
const proc = spawn('node', ['dist/cli.js'], {
|
||||
cwd: path.resolve(__dirname, '..')
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Invalid JSON input');
|
||||
done();
|
||||
});
|
||||
|
||||
proc.stdin.write('not valid json');
|
||||
proc.stdin.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// plugin/hooks/hooks-app/__tests__/config.test.ts
|
||||
import { loadConfig, resolvePluginPath } from '../src/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Config Loading', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns plugin defaults when no project config exists', async () => {
|
||||
// Config loader now returns plugin defaults when no project config exists
|
||||
// This provides fallback behavior without requiring every project to have gates.json
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config).not.toBeNull();
|
||||
// Verify it's actually plugin defaults by checking for expected structure
|
||||
expect(config?.hooks).toBeDefined();
|
||||
expect(config?.gates).toBeDefined();
|
||||
});
|
||||
|
||||
test('loads .claude/gates.json with highest priority', async () => {
|
||||
const claudeDir = path.join(testDir, '.claude');
|
||||
await fs.mkdir(claudeDir);
|
||||
|
||||
const config1 = { hooks: {}, gates: { test: { command: 'claude-config' } } };
|
||||
const config2 = { hooks: {}, gates: { test: { command: 'root-config' } } };
|
||||
|
||||
await fs.writeFile(path.join(claudeDir, 'gates.json'), JSON.stringify(config1));
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config2));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.gates.test.command).toBe('claude-config');
|
||||
});
|
||||
|
||||
test('loads gates.json from root when .claude does not exist', async () => {
|
||||
const config1 = { hooks: {}, gates: { test: { command: 'root-config' } } };
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config1));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.gates.test.command).toBe('root-config');
|
||||
});
|
||||
|
||||
test('parses valid JSON config', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
enabled_tools: ['Edit', 'Write'],
|
||||
gates: ['format', 'test']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
format: { command: 'npm run format', on_pass: 'CONTINUE' },
|
||||
test: { command: 'npm test', on_pass: 'CONTINUE' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config?.hooks.PostToolUse.enabled_tools).toEqual(['Edit', 'Write']);
|
||||
expect(config?.gates.format.command).toBe('npm run format');
|
||||
});
|
||||
|
||||
test('rejects unknown hook event', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
UnknownEvent: { gates: [] }
|
||||
},
|
||||
gates: {}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow('Unknown hook event');
|
||||
});
|
||||
|
||||
test('rejects undefined gate reference', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: { gates: ['nonexistent'] }
|
||||
},
|
||||
gates: {}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow('references undefined gate');
|
||||
});
|
||||
|
||||
test('rejects invalid action', async () => {
|
||||
const configObj = {
|
||||
hooks: {
|
||||
PostToolUse: { gates: ['test'] }
|
||||
},
|
||||
gates: {
|
||||
test: { command: 'echo test', on_pass: 'INVALID' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
'is not CONTINUE/BLOCK/STOP or valid gate name'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Path Resolution', () => {
|
||||
test('resolves sibling plugin using CLAUDE_PLUGIN_ROOT', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
const result = resolvePluginPath('cipherpowers');
|
||||
expect(result).toBe('/home/user/.claude/plugins/cipherpowers');
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('throws when CLAUDE_PLUGIN_ROOT not set', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
delete process.env.CLAUDE_PLUGIN_ROOT;
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('cipherpowers')).toThrow(
|
||||
'Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set'
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plugin names with path separators', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('../etc')).toThrow(
|
||||
"Invalid plugin name: '../etc' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('foo/bar')).toThrow(
|
||||
"Invalid plugin name: 'foo/bar' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('foo\\bar')).toThrow(
|
||||
"Invalid plugin name: 'foo\\bar' (must not contain path separators)"
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects plugin names with parent directory references', () => {
|
||||
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
|
||||
|
||||
try {
|
||||
expect(() => resolvePluginPath('..')).toThrow(
|
||||
"Invalid plugin name: '..' (must not contain path separators)"
|
||||
);
|
||||
expect(() => resolvePluginPath('..foo')).toThrow(
|
||||
"Invalid plugin name: '..foo' (must not contain path separators)"
|
||||
);
|
||||
} finally {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gate Config Validation', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('rejects gate with plugin but no gate name', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: { plugin: 'cipherpowers' } // Missing gate field
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' has 'plugin' but missing 'gate' field"
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gate with gate name but no plugin', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: { gate: 'plan-compliance' } // Missing plugin field
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' has 'gate' but missing 'plugin' field"
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects gate with both command and plugin', async () => {
|
||||
const configObj = {
|
||||
hooks: { PostToolUse: { gates: ['test'] } },
|
||||
gates: {
|
||||
test: {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance',
|
||||
command: 'npm run lint' // Conflicting
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
await expect(loadConfig(testDir)).rejects.toThrow(
|
||||
"Gate 'test' cannot have both 'command' and 'plugin/gate'"
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid plugin gate reference', async () => {
|
||||
const configObj = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
test: { plugin: 'cipherpowers', gate: 'plan-compliance' }
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
|
||||
// Should not throw validation error for structure
|
||||
// (May fail later when trying to resolve plugin, which is acceptable)
|
||||
const config = await loadConfig(testDir);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.gates.test.plugin).toBe('cipherpowers');
|
||||
expect(config?.gates.test.gate).toBe('plan-compliance');
|
||||
});
|
||||
});
|
||||
69
hooks/hooks-app/__tests__/context.test.ts
Normal file
69
hooks/hooks-app/__tests__/context.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// plugin/hooks/hooks-app/__tests__/context.test.ts
|
||||
import { discoverContextFile } from '../src/context';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Context Injection', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('returns null when no context file exists', async () => {
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('discovers flat context file', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
|
||||
});
|
||||
|
||||
test('discovers slash-command subdirectory', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
|
||||
});
|
||||
|
||||
test('discovers nested slash-command directory', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command', 'test-command');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'start.md'));
|
||||
});
|
||||
|
||||
test('discovers skill context', async () => {
|
||||
const contextDir = path.join(testDir, '.claude', 'context', 'skill');
|
||||
await fs.mkdir(contextDir, { recursive: true });
|
||||
await fs.writeFile(path.join(contextDir, 'test-skill-start.md'), 'content');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-skill', 'start');
|
||||
expect(result).toBe(path.join(contextDir, 'test-skill-start.md'));
|
||||
});
|
||||
|
||||
test('follows priority order - flat wins', async () => {
|
||||
const contextBase = path.join(testDir, '.claude', 'context');
|
||||
await fs.mkdir(path.join(contextBase, 'slash-command'), { recursive: true });
|
||||
|
||||
await fs.writeFile(path.join(contextBase, 'test-command-start.md'), 'flat');
|
||||
await fs.writeFile(path.join(contextBase, 'slash-command', 'test-command-start.md'), 'subdir');
|
||||
|
||||
const result = await discoverContextFile(testDir, 'test-command', 'start');
|
||||
expect(result).toBe(path.join(contextBase, 'test-command-start.md'));
|
||||
});
|
||||
});
|
||||
263
hooks/hooks-app/__tests__/dispatcher.test.ts
Normal file
263
hooks/hooks-app/__tests__/dispatcher.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// plugin/hooks/hooks-app/__tests__/dispatcher.test.ts
|
||||
import { shouldProcessHook, dispatch, gateMatchesKeywords } from '../src/dispatcher';
|
||||
import { HookInput, HookConfig, GateConfig } from '../src/types';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Dispatcher - Event Filtering', () => {
|
||||
test('PostToolUse with enabled tool returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test',
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_tools: ['Edit', 'Write']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('PostToolUse with disabled tool returns false', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test',
|
||||
tool_name: 'Read'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_tools: ['Edit', 'Write']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(false);
|
||||
});
|
||||
|
||||
test('SubagentStop with enabled agent returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
agent_name: 'test-namespace:test-agent'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_agents: ['test-namespace:test-agent']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('SubagentStop with disabled agent returns false', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
agent_name: 'other-agent'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_agents: ['test-namespace:test-agent']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(false);
|
||||
});
|
||||
|
||||
test('SubagentStop checks subagent_name if agent_name missing', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/test',
|
||||
subagent_name: 'test-namespace:test-agent'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
enabled_agents: ['test-namespace:test-agent']
|
||||
};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('UserPromptSubmit always returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
cwd: '/test'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
|
||||
test('No filtering config returns true', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test',
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const hookConfig: HookConfig = {};
|
||||
|
||||
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dispatcher - Gate Chaining', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary directory for test config
|
||||
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gates-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('gate chaining works - gate-a chains to gate-b on pass', async () => {
|
||||
// Create gates.json with chaining config
|
||||
const gatesConfig = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
gates: ['gate-a']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'gate-a': {
|
||||
command: 'echo "gate-a passed"',
|
||||
on_pass: 'gate-b' // Chain to gate-b on pass
|
||||
},
|
||||
'gate-b': {
|
||||
command: 'echo "gate-b passed"',
|
||||
on_pass: 'CONTINUE'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: testDir,
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Should contain output from both gates
|
||||
expect(result.context).toContain('gate-a passed');
|
||||
expect(result.context).toContain('gate-b passed');
|
||||
expect(result.blockReason).toBeUndefined();
|
||||
});
|
||||
|
||||
test('circular chain prevention - exceeds max gate depth', async () => {
|
||||
// Create gates.json with circular chain
|
||||
const gatesConfig = {
|
||||
hooks: {
|
||||
PostToolUse: {
|
||||
gates: ['gate-a']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'gate-a': {
|
||||
command: 'echo "gate-a"',
|
||||
on_pass: 'gate-b'
|
||||
},
|
||||
'gate-b': {
|
||||
command: 'echo "gate-b"',
|
||||
on_pass: 'gate-a' // Circular chain back to gate-a
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: testDir,
|
||||
tool_name: 'Edit'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Should hit circuit breaker
|
||||
expect(result.blockReason).toContain('Exceeded max gate chain depth');
|
||||
expect(result.blockReason).toContain('circular');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyword Matching', () => {
|
||||
test('no keywords - gate always runs', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test'
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, '')).toBe(true);
|
||||
});
|
||||
|
||||
test('empty keywords array - gate always runs', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: []
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('no user message with keywords - gate does not run', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test', 'testing']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(false);
|
||||
expect(gateMatchesKeywords(gateConfig, '')).toBe(false);
|
||||
});
|
||||
|
||||
test('keyword match - case insensitive', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'run the TEST')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'RUN THE Test')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'test this')).toBe(true);
|
||||
});
|
||||
|
||||
test('multiple keywords - any matches', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test', 'testing', 'spec', 'verify']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'run the tests')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'verify this works')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'check the spec')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'we are testing')).toBe(true);
|
||||
});
|
||||
|
||||
test('no keyword match - gate does not run', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test', 'testing']
|
||||
};
|
||||
|
||||
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(false);
|
||||
expect(gateMatchesKeywords(gateConfig, 'run the linter')).toBe(false);
|
||||
});
|
||||
|
||||
test('substring matching - partial word matches', () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'npm test',
|
||||
keywords: ['test']
|
||||
};
|
||||
|
||||
// Intentional substring matching (not word-boundary)
|
||||
expect(gateMatchesKeywords(gateConfig, 'latest version')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'contest results')).toBe(true);
|
||||
expect(gateMatchesKeywords(gateConfig, 'testing')).toBe(true);
|
||||
});
|
||||
});
|
||||
178
hooks/hooks-app/__tests__/gate-loader.test.ts
Normal file
178
hooks/hooks-app/__tests__/gate-loader.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// plugin/hooks/hooks-app/__tests__/gate-loader.test.ts
|
||||
import { executeShellCommand, executeGate, loadPluginGate } from '../src/gate-loader';
|
||||
import { GateConfig, HookInput } from '../src/types';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Gate Loader - Shell Commands', () => {
|
||||
test('executes shell command and returns exit code', async () => {
|
||||
const result = await executeShellCommand('echo "test"', process.cwd());
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('test');
|
||||
});
|
||||
|
||||
test('captures non-zero exit code', async () => {
|
||||
const result = await executeShellCommand('exit 1', process.cwd());
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test('captures stdout', async () => {
|
||||
const result = await executeShellCommand('echo "hello world"', process.cwd());
|
||||
expect(result.output).toContain('hello world');
|
||||
});
|
||||
|
||||
test('captures stderr', async () => {
|
||||
const result = await executeShellCommand('echo "error" >&2', process.cwd());
|
||||
expect(result.output).toContain('error');
|
||||
});
|
||||
|
||||
test('executes in specified directory', async () => {
|
||||
const tmpDir = os.tmpdir();
|
||||
const result = await executeShellCommand('pwd', tmpDir);
|
||||
// macOS may prepend /private to paths
|
||||
expect(result.output.trim()).toMatch(new RegExp(tmpDir.replace('/var/', '(/private)?/var/')));
|
||||
});
|
||||
|
||||
test('timeout returns exit code 124 and timeout message', async () => {
|
||||
const result = await executeShellCommand('sleep 1', process.cwd(), 100);
|
||||
expect(result.exitCode).toBe(124);
|
||||
expect(result.output).toContain('timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gate Loader - executeGate', () => {
|
||||
const mockInput: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: process.cwd()
|
||||
};
|
||||
|
||||
test('shell command gate with exit 0 returns passed=true', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'echo "success"'
|
||||
};
|
||||
|
||||
const result = await executeGate('test-gate', gateConfig, mockInput);
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.result.additionalContext).toContain('success');
|
||||
});
|
||||
|
||||
test('shell command gate with exit 1 returns passed=false', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
command: 'exit 1'
|
||||
};
|
||||
|
||||
const result = await executeGate('test-gate', gateConfig, mockInput);
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
|
||||
test('built-in gate throws error when gate not found', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
// No command = built-in gate
|
||||
};
|
||||
|
||||
await expect(executeGate('nonexistent-gate', gateConfig, mockInput)).rejects.toThrow(
|
||||
'Failed to load built-in gate nonexistent-gate'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Gate Loading', () => {
|
||||
let mockPluginDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock plugin directory structure
|
||||
mockPluginDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
|
||||
const cipherpowersDir = path.join(mockPluginDir, 'cipherpowers', 'hooks');
|
||||
await fs.mkdir(cipherpowersDir, { recursive: true });
|
||||
|
||||
// Create mock gates.json for cipherpowers
|
||||
const gatesConfig = {
|
||||
hooks: {},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
command: 'node dist/gates/plan-compliance.js',
|
||||
on_fail: 'BLOCK'
|
||||
}
|
||||
}
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(cipherpowersDir, 'gates.json'),
|
||||
JSON.stringify(gatesConfig)
|
||||
);
|
||||
|
||||
// Set CLAUDE_PLUGIN_ROOT to point to turboshovel sibling
|
||||
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginDir, 'turboshovel');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
await fs.rm(mockPluginDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('loads gate config from plugin', async () => {
|
||||
const result = await loadPluginGate('cipherpowers', 'plan-compliance');
|
||||
|
||||
expect(result.gateConfig.command).toBe('node dist/gates/plan-compliance.js');
|
||||
expect(result.gateConfig.on_fail).toBe('BLOCK');
|
||||
expect(result.pluginRoot).toBe(path.join(mockPluginDir, 'cipherpowers'));
|
||||
});
|
||||
|
||||
test('throws when plugin gates.json not found', async () => {
|
||||
await expect(loadPluginGate('nonexistent', 'some-gate')).rejects.toThrow(
|
||||
"Cannot find gates.json for plugin 'nonexistent'"
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when gate not found in plugin', async () => {
|
||||
await expect(loadPluginGate('cipherpowers', 'nonexistent-gate')).rejects.toThrow(
|
||||
"Gate 'nonexistent-gate' not found in plugin 'cipherpowers'"
|
||||
);
|
||||
});
|
||||
|
||||
test('validates loaded plugin config structure', async () => {
|
||||
// Create plugin with malformed gates.json
|
||||
const malformedDir = path.join(mockPluginDir, 'malformed', 'hooks');
|
||||
await fs.mkdir(malformedDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(malformedDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'bad-gate': {
|
||||
// Missing required fields (no command, plugin, or gate)
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// This should succeed loading but the gate config is invalid
|
||||
// Validation happens when the gate is used, not when loading
|
||||
const result = await loadPluginGate('malformed', 'bad-gate');
|
||||
expect(result.gateConfig).toBeDefined();
|
||||
});
|
||||
|
||||
test('executeGate handles plugin gate reference', async () => {
|
||||
const gateConfig: GateConfig = {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance'
|
||||
};
|
||||
|
||||
const mockInput: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: '/some/project'
|
||||
};
|
||||
|
||||
// The command from cipherpowers will be executed in cipherpowers plugin dir
|
||||
// For this test, the mock plugin has 'node dist/gates/plan-compliance.js'
|
||||
// which won't exist, so it will fail - but we can verify the flow
|
||||
const result = await executeGate('my-gate', gateConfig, mockInput);
|
||||
|
||||
// Command execution will fail (file doesn't exist) but flow is correct
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
164
hooks/hooks-app/__tests__/integration.test.ts
Normal file
164
hooks/hooks-app/__tests__/integration.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { join, dirname } from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
let testDir: string;
|
||||
let cliPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `integration-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
cliPath = join(__dirname, '../dist/cli.js');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
test('set and get command', async () => {
|
||||
await execAsync(`node ${cliPath} session set active_command /execute ${testDir}`);
|
||||
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
|
||||
expect(stdout.trim()).toBe('/execute');
|
||||
});
|
||||
|
||||
test('append and check contains', async () => {
|
||||
await execAsync(`node ${cliPath} session append file_extensions ts ${testDir}`);
|
||||
|
||||
const result = await execAsync(
|
||||
`node ${cliPath} session contains file_extensions ts ${testDir}`
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('clear removes state', async () => {
|
||||
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
|
||||
await execAsync(`node ${cliPath} session clear ${testDir}`);
|
||||
|
||||
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
|
||||
expect(stdout.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hook Dispatch with Session Tracking', () => {
|
||||
test('PostToolUse updates session', async () => {
|
||||
const hookInput = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Edit',
|
||||
file_path: 'main.ts',
|
||||
cwd: testDir
|
||||
});
|
||||
|
||||
await execAsync(`echo '${hookInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: files } = await execAsync(
|
||||
`node ${cliPath} session get edited_files ${testDir}`
|
||||
);
|
||||
expect(files).toContain('main.ts');
|
||||
|
||||
const containsTs = await execAsync(
|
||||
`node ${cliPath} session contains file_extensions ts ${testDir}`
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(containsTs).toBe(true);
|
||||
});
|
||||
|
||||
test('SlashCommandStart/End updates session', async () => {
|
||||
// Start command
|
||||
const startInput = JSON.stringify({
|
||||
hook_event_name: 'SlashCommandStart',
|
||||
command: '/execute',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${startInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: activeCmd } = await execAsync(
|
||||
`node ${cliPath} session get active_command ${testDir}`
|
||||
);
|
||||
expect(activeCmd.trim()).toBe('/execute');
|
||||
|
||||
// End command
|
||||
const endInput = JSON.stringify({
|
||||
hook_event_name: 'SlashCommandEnd',
|
||||
command: '/execute',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${endInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: cleared } = await execAsync(
|
||||
`node ${cliPath} session get active_command ${testDir}`
|
||||
);
|
||||
expect(cleared.trim()).toBe('');
|
||||
});
|
||||
|
||||
test('SkillStart/End updates session', async () => {
|
||||
// Start skill
|
||||
const startInput = JSON.stringify({
|
||||
hook_event_name: 'SkillStart',
|
||||
skill: 'executing-plans',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${startInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: activeSkill } = await execAsync(
|
||||
`node ${cliPath} session get active_skill ${testDir}`
|
||||
);
|
||||
expect(activeSkill.trim()).toBe('executing-plans');
|
||||
|
||||
// End skill
|
||||
const endInput = JSON.stringify({
|
||||
hook_event_name: 'SkillEnd',
|
||||
skill: 'executing-plans',
|
||||
cwd: testDir
|
||||
});
|
||||
await execAsync(`echo '${endInput}' | node ${cliPath}`);
|
||||
|
||||
const { stdout: cleared } = await execAsync(
|
||||
`node ${cliPath} session get active_skill ${testDir}`
|
||||
);
|
||||
expect(cleared.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('handles corrupted state file gracefully', async () => {
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
await fs.mkdir(dirname(stateFile), { recursive: true });
|
||||
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
|
||||
|
||||
// Should reinitialize and work
|
||||
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
|
||||
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
|
||||
expect(stdout.trim()).toBe('/plan');
|
||||
});
|
||||
|
||||
test('rejects invalid session keys', async () => {
|
||||
try {
|
||||
await execAsync(`node ${cliPath} session get invalid_key ${testDir}`);
|
||||
fail('Should have thrown error');
|
||||
} catch (error) {
|
||||
const err = error as { stderr?: string };
|
||||
expect(err.stderr).toContain('Invalid session key');
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects invalid array keys for append', async () => {
|
||||
try {
|
||||
await execAsync(`node ${cliPath} session append invalid_key value ${testDir}`);
|
||||
fail('Should have thrown error');
|
||||
} catch (error) {
|
||||
const err = error as { stderr?: string };
|
||||
expect(err.stderr).toContain('Invalid array key');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
239
hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
Normal file
239
hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// plugin/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
|
||||
import { dispatch } from '../src/dispatcher';
|
||||
import { HookInput } from '../src/types';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
describe('Plugin Gate Composition Integration', () => {
|
||||
let mockPluginsDir: string;
|
||||
let projectDir: string;
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock plugins directory with two plugins
|
||||
mockPluginsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
|
||||
|
||||
// Create mock cipherpowers plugin
|
||||
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
|
||||
await fs.mkdir(cipherpowersHooksDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cipherpowersHooksDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
command: 'echo "plan-compliance check passed"',
|
||||
on_fail: 'BLOCK'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Create mock turboshovel plugin (current plugin)
|
||||
const turboshovelHooksDir = path.join(mockPluginsDir, 'turboshovel', 'hooks');
|
||||
await fs.mkdir(turboshovelHooksDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(turboshovelHooksDir, 'gates.json'),
|
||||
JSON.stringify({ hooks: {}, gates: {} })
|
||||
);
|
||||
|
||||
// Create test project directory
|
||||
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-project-'));
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
await fs.mkdir(claudeDir);
|
||||
|
||||
// Project config references cipherpowers gate
|
||||
await fs.writeFile(
|
||||
path.join(claudeDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
SubagentStop: {
|
||||
gates: ['plan-compliance', 'check']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance'
|
||||
},
|
||||
'check': {
|
||||
command: 'echo "project check passed"'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Set CLAUDE_PLUGIN_ROOT
|
||||
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginsDir, 'turboshovel');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
|
||||
await fs.rm(mockPluginsDir, { recursive: true, force: true });
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('executes plugin gate followed by project gate', async () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Both gates should pass (no blockReason or stopMessage)
|
||||
expect(result.blockReason).toBeUndefined();
|
||||
expect(result.stopMessage).toBeUndefined();
|
||||
|
||||
// Should have output from both gates
|
||||
expect(result.context).toContain('plan-compliance check passed');
|
||||
expect(result.context).toContain('project check passed');
|
||||
});
|
||||
|
||||
test('plugin gate BLOCK stops execution', async () => {
|
||||
// Update cipherpowers gate to fail
|
||||
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
|
||||
await fs.writeFile(
|
||||
path.join(cipherpowersHooksDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'plan-compliance': {
|
||||
command: 'exit 1',
|
||||
on_fail: 'BLOCK'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Should be blocked (blockReason will be set)
|
||||
expect(result.blockReason).toBeDefined();
|
||||
});
|
||||
|
||||
test('prevents circular gate references', async () => {
|
||||
// Create circular reference: pluginA -> pluginB -> pluginA
|
||||
const pluginADir = path.join(mockPluginsDir, 'pluginA', 'hooks');
|
||||
const pluginBDir = path.join(mockPluginsDir, 'pluginB', 'hooks');
|
||||
await fs.mkdir(pluginADir, { recursive: true });
|
||||
await fs.mkdir(pluginBDir, { recursive: true });
|
||||
|
||||
// PluginA has gate that references pluginB
|
||||
await fs.writeFile(
|
||||
path.join(pluginADir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'gateA': {
|
||||
plugin: 'pluginB',
|
||||
gate: 'gateB'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// PluginB has gate that references pluginA (circular)
|
||||
await fs.writeFile(
|
||||
path.join(pluginBDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'gateB': {
|
||||
plugin: 'pluginA',
|
||||
gate: 'gateA'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Project config references pluginA gate
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
await fs.writeFile(
|
||||
path.join(claudeDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
SubagentStop: {
|
||||
gates: ['test-circular']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'test-circular': {
|
||||
plugin: 'pluginA',
|
||||
gate: 'gateA'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
// Should error or handle gracefully (not infinite loop)
|
||||
// Implementation decision: error on circular reference
|
||||
await expect(dispatch(input)).rejects.toThrow(/circular|depth|recursion/i);
|
||||
});
|
||||
|
||||
test('handles plugin self-reference', async () => {
|
||||
// Plugin references its own gate
|
||||
const selfRefDir = path.join(mockPluginsDir, 'selfref', 'hooks');
|
||||
await fs.mkdir(selfRefDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(selfRefDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {},
|
||||
gates: {
|
||||
'gate1': {
|
||||
command: 'echo "gate1"'
|
||||
},
|
||||
'gate2': {
|
||||
plugin: 'selfref',
|
||||
gate: 'gate1'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Project references the self-referencing gate
|
||||
const claudeDir = path.join(projectDir, '.claude');
|
||||
await fs.writeFile(
|
||||
path.join(claudeDir, 'gates.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
SubagentStop: {
|
||||
gates: ['test-self']
|
||||
}
|
||||
},
|
||||
gates: {
|
||||
'test-self': {
|
||||
plugin: 'selfref',
|
||||
gate: 'gate2'
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'SubagentStop',
|
||||
cwd: projectDir,
|
||||
agent_name: 'test-agent'
|
||||
};
|
||||
|
||||
// Should work - self-reference to a different gate is valid
|
||||
const result = await dispatch(input);
|
||||
expect(result.blockReason).toBeUndefined();
|
||||
expect(result.context).toContain('gate1');
|
||||
});
|
||||
});
|
||||
198
hooks/hooks-app/__tests__/session.test.ts
Normal file
198
hooks/hooks-app/__tests__/session.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Session } from '../src/session';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('Session', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `session-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
test('sets state file path', () => {
|
||||
const session = new Session(testDir);
|
||||
expect(session['stateFile']).toBe(join(testDir, '.claude', 'session', 'state.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('get/set', () => {
|
||||
test('set and get scalar value', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
|
||||
const value = await session.get('active_command');
|
||||
expect(value).toBe('/execute');
|
||||
});
|
||||
|
||||
test('get returns null for unset values', async () => {
|
||||
const session = new Session(testDir);
|
||||
const value = await session.get('active_skill');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
test('set multiple values independently', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
await session.set('active_skill', 'executing-plans');
|
||||
|
||||
expect(await session.get('active_command')).toBe('/execute');
|
||||
expect(await session.get('active_skill')).toBe('executing-plans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('append/contains', () => {
|
||||
test('append adds value to array', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('edited_files', 'main.ts');
|
||||
await session.append('edited_files', 'lib.ts');
|
||||
|
||||
const files = await session.get('edited_files');
|
||||
expect(files).toEqual(['main.ts', 'lib.ts']);
|
||||
});
|
||||
|
||||
test('append deduplicates values', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('edited_files', 'main.ts');
|
||||
await session.append('edited_files', 'lib.ts');
|
||||
await session.append('edited_files', 'main.ts'); // Duplicate
|
||||
|
||||
const files = await session.get('edited_files');
|
||||
expect(files).toEqual(['main.ts', 'lib.ts']);
|
||||
});
|
||||
|
||||
test('contains returns true for existing value', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('file_extensions', 'ts');
|
||||
await session.append('file_extensions', 'js');
|
||||
|
||||
expect(await session.contains('file_extensions', 'ts')).toBe(true);
|
||||
expect(await session.contains('file_extensions', 'js')).toBe(true);
|
||||
});
|
||||
|
||||
test('contains returns false for missing value', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.append('file_extensions', 'ts');
|
||||
|
||||
expect(await session.contains('file_extensions', 'rs')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
test('removes state file', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
const exists = await fs
|
||||
.access(stateFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
await session.clear();
|
||||
|
||||
const existsAfter = await fs
|
||||
.access(stateFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(existsAfter).toBe(false);
|
||||
});
|
||||
|
||||
test('is safe when file does not exist', async () => {
|
||||
const session = new Session(testDir);
|
||||
await expect(session.clear()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
test('state persists across Session instances', async () => {
|
||||
const session1 = new Session(testDir);
|
||||
await session1.set('active_command', '/plan');
|
||||
await session1.append('edited_files', 'main.ts');
|
||||
|
||||
const session2 = new Session(testDir);
|
||||
expect(await session2.get('active_command')).toBe('/plan');
|
||||
expect(await session2.get('edited_files')).toEqual(['main.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomic writes', () => {
|
||||
test('uses atomic rename', async () => {
|
||||
const session = new Session(testDir);
|
||||
await session.set('active_command', '/execute');
|
||||
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
const tempFile = stateFile + '.tmp';
|
||||
|
||||
// Temp file should not exist after save completes
|
||||
const tempExists = await fs
|
||||
.access(tempFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(tempExists).toBe(false);
|
||||
|
||||
// State file should exist
|
||||
const stateExists = await fs
|
||||
.access(stateFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(stateExists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error scenarios', () => {
|
||||
test('handles corrupted JSON gracefully', async () => {
|
||||
const session = new Session(testDir);
|
||||
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
||||
|
||||
// Create directory and write corrupted JSON
|
||||
await fs.mkdir(dirname(stateFile), { recursive: true });
|
||||
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
|
||||
|
||||
// Should reinitialize state on corruption
|
||||
const value = await session.get('active_command');
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
test('handles cross-process persistence', async () => {
|
||||
// Simulate separate process invocations
|
||||
const session1 = new Session(testDir);
|
||||
await session1.set('active_command', '/execute');
|
||||
await session1.append('edited_files', 'main.ts');
|
||||
|
||||
// Create new session instance (simulates new process)
|
||||
const session2 = new Session(testDir);
|
||||
expect(await session2.get('active_command')).toBe('/execute');
|
||||
expect(await session2.get('edited_files')).toEqual(['main.ts']);
|
||||
});
|
||||
|
||||
test('handles concurrent writes via atomic rename', async () => {
|
||||
const session = new Session(testDir);
|
||||
|
||||
// Rapid concurrent writes (atomic rename prevents corruption)
|
||||
// Note: Some writes may fail due to temp file conflicts, but state file
|
||||
// should never be corrupted (that's what atomic rename protects against)
|
||||
const results = await Promise.allSettled([
|
||||
session.append('edited_files', 'file1.ts'),
|
||||
session.append('edited_files', 'file2.ts'),
|
||||
session.append('edited_files', 'file3.ts')
|
||||
]);
|
||||
|
||||
// At least one operation should succeed
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
||||
expect(successCount).toBeGreaterThan(0);
|
||||
|
||||
// State file should be valid (not corrupted)
|
||||
const files = await session.get('edited_files');
|
||||
expect(Array.isArray(files)).toBe(true);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
hooks/hooks-app/__tests__/types.test.ts
Normal file
63
hooks/hooks-app/__tests__/types.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// plugin/hooks/hooks-app/__tests__/types.test.ts
|
||||
import { HookInput, GateResult, GateConfig } from '../src/types';
|
||||
|
||||
describe('Types', () => {
|
||||
test('HookInput has required fields', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test/path'
|
||||
};
|
||||
expect(input.hook_event_name).toBe('PostToolUse');
|
||||
expect(input.cwd).toBe('/test/path');
|
||||
});
|
||||
|
||||
test('HookInput accepts optional PostToolUse fields', () => {
|
||||
const input: HookInput = {
|
||||
hook_event_name: 'PostToolUse',
|
||||
cwd: '/test/path',
|
||||
tool_name: 'Edit',
|
||||
file_path: '/test/file.ts'
|
||||
};
|
||||
expect(input.tool_name).toBe('Edit');
|
||||
expect(input.file_path).toBe('/test/file.ts');
|
||||
});
|
||||
|
||||
test('GateResult can be empty object', () => {
|
||||
const result: GateResult = {};
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test('GateResult can have additionalContext', () => {
|
||||
const result: GateResult = {
|
||||
additionalContext: 'Test context'
|
||||
};
|
||||
expect(result.additionalContext).toBe('Test context');
|
||||
});
|
||||
|
||||
test('GateResult can have block decision', () => {
|
||||
const result: GateResult = {
|
||||
decision: 'block',
|
||||
reason: 'Test reason'
|
||||
};
|
||||
expect(result.decision).toBe('block');
|
||||
expect(result.reason).toBe('Test reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GateConfig Type', () => {
|
||||
test('accepts plugin gate reference', () => {
|
||||
const config: GateConfig = {
|
||||
plugin: 'cipherpowers',
|
||||
gate: 'plan-compliance'
|
||||
};
|
||||
expect(config.plugin).toBe('cipherpowers');
|
||||
expect(config.gate).toBe('plan-compliance');
|
||||
});
|
||||
|
||||
test('accepts local command gate', () => {
|
||||
const config: GateConfig = {
|
||||
command: 'npm run lint'
|
||||
};
|
||||
expect(config.command).toBe('npm run lint');
|
||||
});
|
||||
});
|
||||
8
hooks/hooks-app/jest.config.js
Normal file
8
hooks/hooks-app/jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/__tests__'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json']
|
||||
};
|
||||
31
hooks/hooks-app/package.json
Normal file
31
hooks/hooks-app/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@turboshovel/hooks-app",
|
||||
"version": "1.0.0",
|
||||
"description": "TypeScript hooks dispatcher for Turboshovel",
|
||||
"main": "dist/cli.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/**/*.ts __tests__/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts __tests__/**/*.ts --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"__tests__/**/*.ts\"",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
}
|
||||
45
hooks/hooks-app/src/action-handler.ts
Normal file
45
hooks/hooks-app/src/action-handler.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// plugin/hooks/hooks-app/src/action-handler.ts
|
||||
import { GateResult, GatesConfig, HookInput } from './types';
|
||||
|
||||
export interface ActionResult {
|
||||
continue: boolean;
|
||||
context?: string;
|
||||
blockReason?: string;
|
||||
stopMessage?: string;
|
||||
chainedGate?: string;
|
||||
}
|
||||
|
||||
export async function handleAction(
|
||||
action: string,
|
||||
gateResult: GateResult,
|
||||
_config: GatesConfig,
|
||||
_input: HookInput
|
||||
): Promise<ActionResult> {
|
||||
switch (action) {
|
||||
case 'CONTINUE':
|
||||
return {
|
||||
continue: true,
|
||||
context: gateResult.additionalContext
|
||||
};
|
||||
|
||||
case 'BLOCK':
|
||||
return {
|
||||
continue: false,
|
||||
blockReason: gateResult.reason || 'Gate failed'
|
||||
};
|
||||
|
||||
case 'STOP':
|
||||
return {
|
||||
continue: false,
|
||||
stopMessage: gateResult.message || 'Gate stopped execution'
|
||||
};
|
||||
|
||||
default:
|
||||
// Gate chaining - action is another gate name
|
||||
return {
|
||||
continue: true,
|
||||
context: gateResult.additionalContext,
|
||||
chainedGate: action
|
||||
};
|
||||
}
|
||||
}
|
||||
268
hooks/hooks-app/src/cli.ts
Normal file
268
hooks/hooks-app/src/cli.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// plugin/hooks/hooks-app/src/cli.ts
|
||||
import { HookInput, SessionState, SessionStateArrayKey } from './types';
|
||||
import { dispatch } from './dispatcher';
|
||||
import { Session } from './session';
|
||||
import { logger } from './logger';
|
||||
|
||||
interface OutputMessage {
|
||||
additionalContext?: string;
|
||||
decision?: string;
|
||||
reason?: string;
|
||||
continue?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Check if first arg is "session" - session management mode
|
||||
if (args.length > 0 && args[0] === 'session') {
|
||||
await handleSessionCommand(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if first arg is "log-path" - return log file path for mise tasks
|
||||
if (args.length > 0 && args[0] === 'log-path') {
|
||||
console.log(logger.getLogFilePath());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if first arg is "log-dir" - return log directory for mise tasks
|
||||
if (args.length > 0 && args[0] === 'log-dir') {
|
||||
console.log(logger.getLogDir());
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, hook dispatch mode (existing behavior)
|
||||
await handleHookDispatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for SessionState keys
|
||||
*/
|
||||
function isSessionStateKey(key: string): key is keyof SessionState {
|
||||
const validKeys = [
|
||||
'session_id',
|
||||
'started_at',
|
||||
'active_command',
|
||||
'active_skill',
|
||||
'edited_files',
|
||||
'file_extensions',
|
||||
'metadata'
|
||||
] as const;
|
||||
return (validKeys as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for array keys
|
||||
*/
|
||||
function isArrayKey(key: string): key is SessionStateArrayKey {
|
||||
return key === 'edited_files' || key === 'file_extensions';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session management commands with proper type safety
|
||||
*/
|
||||
async function handleSessionCommand(args: string[]): Promise<void> {
|
||||
if (args.length < 1) {
|
||||
console.error('Usage: hooks-app session [get|set|append|contains|clear] ...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [command, ...params] = args;
|
||||
const cwd = params[params.length - 1] || '.';
|
||||
const session = new Session(cwd);
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'get': {
|
||||
if (params.length < 2) {
|
||||
console.error('Usage: hooks-app session get <key> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key] = params;
|
||||
if (!isSessionStateKey(key)) {
|
||||
console.error(`Invalid session key: ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const value = await session.get(key);
|
||||
console.log(value ?? '');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set': {
|
||||
if (params.length < 3) {
|
||||
console.error('Usage: hooks-app session set <key> <value> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key, value] = params;
|
||||
if (!isSessionStateKey(key)) {
|
||||
console.error(`Invalid session key: ${key}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Type-safe set with runtime validation
|
||||
if (key === 'active_command' || key === 'active_skill') {
|
||||
await session.set(key, value === 'null' ? null : value);
|
||||
} else if (key === 'metadata') {
|
||||
await session.set(key, JSON.parse(value));
|
||||
} else {
|
||||
console.error(`Cannot set ${key} via CLI (use get, append, or contains)`);
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
if (params.length < 3) {
|
||||
console.error('Usage: hooks-app session append <key> <value> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key, value] = params;
|
||||
if (!isArrayKey(key)) {
|
||||
console.error(`Invalid array key: ${key} (must be edited_files or file_extensions)`);
|
||||
process.exit(1);
|
||||
}
|
||||
await session.append(key, value);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'contains': {
|
||||
if (params.length < 3) {
|
||||
console.error('Usage: hooks-app session contains <key> <value> [cwd]');
|
||||
process.exit(1);
|
||||
}
|
||||
const [key, value] = params;
|
||||
if (!isArrayKey(key)) {
|
||||
console.error(`Invalid array key: ${key} (must be edited_files or file_extensions)`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = await session.contains(key, value);
|
||||
process.exit(result ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
await session.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown session command: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await logger.error('Session command failed', { command, error: errorMessage });
|
||||
console.error(`Session error: ${errorMessage}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hook dispatch (existing behavior)
|
||||
*/
|
||||
async function handleHookDispatch(): Promise<void> {
|
||||
try {
|
||||
// Read stdin
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const inputStr = Buffer.concat(chunks).toString('utf-8');
|
||||
|
||||
// ALWAYS log hook invocation (unconditional - for debugging)
|
||||
await logger.always('HOOK_INVOKED', {
|
||||
input_length: inputStr.length,
|
||||
input_preview: inputStr.substring(0, 500)
|
||||
});
|
||||
|
||||
// Log raw input at CLI entry point
|
||||
await logger.debug('CLI received hook input', {
|
||||
input_length: inputStr.length,
|
||||
input_preview: inputStr.substring(0, 200)
|
||||
});
|
||||
|
||||
// Parse input
|
||||
let input: HookInput;
|
||||
try {
|
||||
input = JSON.parse(inputStr);
|
||||
} catch (error) {
|
||||
await logger.error('CLI failed to parse JSON input', {
|
||||
input_preview: inputStr.substring(0, 200),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
continue: false,
|
||||
message: 'Invalid JSON input'
|
||||
})
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Log parsed hook event
|
||||
await logger.info('CLI dispatching hook', {
|
||||
event: input.hook_event_name,
|
||||
cwd: input.cwd,
|
||||
tool: input.tool_name,
|
||||
agent: input.agent_name,
|
||||
command: input.command,
|
||||
skill: input.skill
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!input.hook_event_name || !input.cwd) {
|
||||
await logger.warn('CLI missing required fields, exiting', {
|
||||
has_event: !!input.hook_event_name,
|
||||
has_cwd: !!input.cwd
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch
|
||||
const result = await dispatch(input);
|
||||
|
||||
// Build output
|
||||
const output: OutputMessage = {};
|
||||
|
||||
if (result.context) {
|
||||
output.additionalContext = result.context;
|
||||
}
|
||||
|
||||
if (result.blockReason) {
|
||||
output.decision = 'block';
|
||||
output.reason = result.blockReason;
|
||||
}
|
||||
|
||||
if (result.stopMessage) {
|
||||
output.continue = false;
|
||||
output.message = result.stopMessage;
|
||||
}
|
||||
|
||||
// Log result
|
||||
await logger.info('CLI hook completed', {
|
||||
event: input.hook_event_name,
|
||||
has_context: !!result.context,
|
||||
has_block: !!result.blockReason,
|
||||
has_stop: !!result.stopMessage,
|
||||
output_keys: Object.keys(output)
|
||||
});
|
||||
|
||||
// Write output
|
||||
if (Object.keys(output).length > 0) {
|
||||
console.log(JSON.stringify(output));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await logger.error('Hook dispatch failed', { error: errorMessage });
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
continue: false,
|
||||
message: `Unexpected error: ${error}`
|
||||
})
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
219
hooks/hooks-app/src/config.ts
Normal file
219
hooks/hooks-app/src/config.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// plugin/hooks/hooks-app/src/config.ts
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { GatesConfig, HookConfig, GateConfig } from './types';
|
||||
import { fileExists } from './utils';
|
||||
import { logger } from './logger';
|
||||
|
||||
const KNOWN_HOOK_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'SubagentStop',
|
||||
'UserPromptSubmit',
|
||||
'SlashCommandStart',
|
||||
'SlashCommandEnd',
|
||||
'SkillStart',
|
||||
'SkillEnd',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'Stop',
|
||||
'Notification'
|
||||
];
|
||||
|
||||
const KNOWN_ACTIONS = ['CONTINUE', 'BLOCK', 'STOP'];
|
||||
|
||||
function validateGateConfig(gateName: string, gateConfig: GateConfig): void {
|
||||
const hasPlugin = gateConfig.plugin !== undefined;
|
||||
const hasGate = gateConfig.gate !== undefined;
|
||||
const hasCommand = gateConfig.command !== undefined;
|
||||
|
||||
// plugin requires gate
|
||||
if (hasPlugin && !hasGate) {
|
||||
throw new Error(`Gate '${gateName}' has 'plugin' but missing 'gate' field`);
|
||||
}
|
||||
|
||||
// gate requires plugin
|
||||
if (hasGate && !hasPlugin) {
|
||||
throw new Error(`Gate '${gateName}' has 'gate' but missing 'plugin' field`);
|
||||
}
|
||||
|
||||
// command is mutually exclusive with plugin/gate
|
||||
if (hasCommand && (hasPlugin || hasGate)) {
|
||||
throw new Error(`Gate '${gateName}' cannot have both 'command' and 'plugin/gate'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config invariants to catch configuration errors early.
|
||||
* Throws descriptive errors when invariants are violated.
|
||||
*/
|
||||
export function validateConfig(config: GatesConfig): void {
|
||||
// Invariant: Hook event names must be known types
|
||||
for (const hookName of Object.keys(config.hooks)) {
|
||||
if (!KNOWN_HOOK_EVENTS.includes(hookName)) {
|
||||
throw new Error(
|
||||
`Unknown hook event: ${hookName}. Must be one of: ${KNOWN_HOOK_EVENTS.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant: Gates referenced in hooks must exist in gates config
|
||||
for (const [hookName, hookConfig] of Object.entries(config.hooks)) {
|
||||
if (hookConfig.gates) {
|
||||
for (const gateName of hookConfig.gates) {
|
||||
if (!config.gates[gateName]) {
|
||||
throw new Error(`Hook '${hookName}' references undefined gate '${gateName}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invariant: Gate actions must be CONTINUE/BLOCK/STOP or reference existing gates
|
||||
for (const [gateName, gateConfig] of Object.entries(config.gates)) {
|
||||
// Validate gate structure first
|
||||
validateGateConfig(gateName, gateConfig);
|
||||
|
||||
for (const action of [gateConfig.on_pass, gateConfig.on_fail]) {
|
||||
if (action && !KNOWN_ACTIONS.includes(action) && !config.gates[action]) {
|
||||
throw new Error(
|
||||
`Gate '${gateName}' action '${action}' is not CONTINUE/BLOCK/STOP or valid gate name`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve plugin path using sibling convention.
|
||||
* Assumes plugins are installed as siblings under the same parent directory.
|
||||
*
|
||||
* SECURITY: Plugin names are validated to prevent path traversal attacks.
|
||||
* This does NOT mean untrusted plugins are safe - plugins are trusted by virtue
|
||||
* of being explicitly installed by the user. This validation only prevents
|
||||
* accidental or malicious config entries from accessing arbitrary paths.
|
||||
*
|
||||
* @param pluginName - Name of the plugin to resolve
|
||||
* @returns Absolute path to the plugin root
|
||||
* @throws Error if CLAUDE_PLUGIN_ROOT is not set or plugin name is invalid
|
||||
*/
|
||||
export function resolvePluginPath(pluginName: string): string {
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (!pluginRoot) {
|
||||
throw new Error('Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set');
|
||||
}
|
||||
|
||||
// Security: Reject plugin names with path separators or parent references
|
||||
// Prevents path traversal attacks like "../../../etc" or "foo/bar"
|
||||
if (pluginName.includes('/') || pluginName.includes('\\') || pluginName.includes('..')) {
|
||||
throw new Error(
|
||||
`Invalid plugin name: '${pluginName}' (must not contain path separators)`
|
||||
);
|
||||
}
|
||||
|
||||
// Sibling convention: plugins are in same parent directory
|
||||
// e.g., ~/.claude/plugins/turboshovel -> ~/.claude/plugins/cipherpowers
|
||||
return path.resolve(pluginRoot, '..', pluginName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin root directory from CLAUDE_PLUGIN_ROOT env var.
|
||||
* Falls back to computing relative to this file's location.
|
||||
*/
|
||||
function getPluginRoot(): string | null {
|
||||
const envRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (envRoot) {
|
||||
return envRoot;
|
||||
}
|
||||
|
||||
// Fallback: compute from this file's location
|
||||
// This file is at: plugin/hooks/hooks-app/src/config.ts (dev)
|
||||
// Or at: plugin/hooks/hooks-app/dist/config.js (built)
|
||||
// Plugin root is: plugin/
|
||||
try {
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single config file
|
||||
*/
|
||||
export async function loadConfigFile(configPath: string): Promise<GatesConfig | null> {
|
||||
if (await fileExists(configPath)) {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two configs. Project config takes precedence over plugin config.
|
||||
* - hooks: project hooks override plugin hooks for same event
|
||||
* - gates: project gates override plugin gates for same name
|
||||
*/
|
||||
function mergeConfigs(pluginConfig: GatesConfig, projectConfig: GatesConfig): GatesConfig {
|
||||
return {
|
||||
hooks: {
|
||||
...pluginConfig.hooks,
|
||||
...projectConfig.hooks
|
||||
},
|
||||
gates: {
|
||||
...pluginConfig.gates,
|
||||
...projectConfig.gates
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge project and plugin configs.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Project: .claude/gates.json (highest)
|
||||
* 2. Project: gates.json
|
||||
* 3. Plugin: ${CLAUDE_PLUGIN_ROOT}/hooks/gates.json (fallback/defaults)
|
||||
*
|
||||
* Configs are MERGED - project overrides plugin for same keys.
|
||||
*/
|
||||
export async function loadConfig(cwd: string): Promise<GatesConfig | null> {
|
||||
const pluginRoot = getPluginRoot();
|
||||
|
||||
// Load plugin config first (defaults)
|
||||
let mergedConfig: GatesConfig | null = null;
|
||||
|
||||
if (pluginRoot) {
|
||||
const pluginConfigPath = path.join(pluginRoot, 'hooks', 'gates.json');
|
||||
const pluginConfig = await loadConfigFile(pluginConfigPath);
|
||||
if (pluginConfig) {
|
||||
await logger.debug('Loaded plugin gates.json', { path: pluginConfigPath });
|
||||
mergedConfig = pluginConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Load project config (overrides)
|
||||
const projectPaths = [
|
||||
path.join(cwd, '.claude', 'gates.json'),
|
||||
path.join(cwd, 'gates.json')
|
||||
];
|
||||
|
||||
for (const configPath of projectPaths) {
|
||||
const projectConfig = await loadConfigFile(configPath);
|
||||
if (projectConfig) {
|
||||
await logger.debug('Loaded project gates.json', { path: configPath });
|
||||
if (mergedConfig) {
|
||||
mergedConfig = mergeConfigs(mergedConfig, projectConfig);
|
||||
await logger.debug('Merged project config with plugin config');
|
||||
} else {
|
||||
mergedConfig = projectConfig;
|
||||
}
|
||||
break; // Only load first project config found
|
||||
}
|
||||
}
|
||||
|
||||
// Validate merged config
|
||||
if (mergedConfig) {
|
||||
validateConfig(mergedConfig);
|
||||
}
|
||||
|
||||
return mergedConfig;
|
||||
}
|
||||
280
hooks/hooks-app/src/context.ts
Normal file
280
hooks/hooks-app/src/context.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
// plugin/hooks/hooks-app/src/context.ts
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { HookInput } from './types';
|
||||
import { fileExists } from './utils';
|
||||
import { Session } from './session';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Get the plugin root directory from CLAUDE_PLUGIN_ROOT env var.
|
||||
* Falls back to computing relative to this file's location.
|
||||
*/
|
||||
function getPluginRoot(): string | null {
|
||||
// First check env var (set by Claude Code when plugin is loaded)
|
||||
const envRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||||
if (envRoot) {
|
||||
return envRoot;
|
||||
}
|
||||
|
||||
// Fallback: compute from this file's location
|
||||
// This file is at: plugin/hooks/hooks-app/src/context.ts (dev)
|
||||
// Or at: plugin/hooks/hooks-app/dist/context.js (built)
|
||||
// Plugin root is: plugin/
|
||||
try {
|
||||
// Go up from src/ or dist/ -> hooks-app/ -> hooks/ -> plugin/
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context file paths for a given base directory.
|
||||
* Returns array of paths following priority order:
|
||||
* flat > slash-command subdir > slash-command nested > skill subdir > skill nested
|
||||
*/
|
||||
function buildContextPaths(baseDir: string, contextDir: string, name: string, stage: string): string[] {
|
||||
return [
|
||||
path.join(baseDir, contextDir, `${name}-${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'slash-command', `${name}-${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'slash-command', name, `${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'skill', `${name}-${stage}.md`),
|
||||
path.join(baseDir, contextDir, 'skill', name, `${stage}.md`)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover context file following priority order.
|
||||
*
|
||||
* Priority (project takes precedence over plugin):
|
||||
* 1. Project: .claude/context/{name}-{stage}.md (and variations)
|
||||
* 2. Plugin: ${CLAUDE_PLUGIN_ROOT}/context/{name}-{stage}.md (and variations)
|
||||
*/
|
||||
export async function discoverContextFile(
|
||||
cwd: string,
|
||||
name: string,
|
||||
stage: string
|
||||
): Promise<string | null> {
|
||||
// Project-level context (highest priority)
|
||||
const projectPaths = buildContextPaths(cwd, '.claude/context', name, stage);
|
||||
|
||||
for (const filePath of projectPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found project context file', { path: filePath, name, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin-level context (fallback)
|
||||
const pluginRoot = getPluginRoot();
|
||||
if (pluginRoot) {
|
||||
const pluginPaths = buildContextPaths(pluginRoot, 'context', name, stage);
|
||||
|
||||
for (const filePath of pluginPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found plugin context file', { path: filePath, name, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover agent-command scoped context file.
|
||||
* Pattern: {agent}-{command}-{stage}.md
|
||||
*
|
||||
* Priority:
|
||||
* 1. Project: {agent}-{command}-{stage}.md (most specific)
|
||||
* 2. Project: {agent}-{stage}.md (agent-specific)
|
||||
* 3. Plugin: {agent}-{command}-{stage}.md
|
||||
* 4. Plugin: {agent}-{stage}.md
|
||||
* 5. Standard discovery (backward compat, checks both project and plugin)
|
||||
*/
|
||||
async function discoverAgentCommandContext(
|
||||
cwd: string,
|
||||
agent: string,
|
||||
commandOrSkill: string | null,
|
||||
stage: string
|
||||
): Promise<string | null> {
|
||||
// Strip namespace prefix from agent name (namespace:agent-name → agent-name)
|
||||
const agentName = agent.replace(/^[^:]+:/, '');
|
||||
const contextName = commandOrSkill?.replace(/^\//, '').replace(/^[^:]+:/, '');
|
||||
|
||||
// Project-level paths (highest priority)
|
||||
const projectPaths: string[] = [];
|
||||
if (contextName) {
|
||||
projectPaths.push(path.join(cwd, '.claude', 'context', `${agentName}-${contextName}-${stage}.md`));
|
||||
}
|
||||
projectPaths.push(path.join(cwd, '.claude', 'context', `${agentName}-${stage}.md`));
|
||||
|
||||
for (const filePath of projectPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found project agent context file', { path: filePath, agent: agentName, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin-level paths (fallback)
|
||||
const pluginRoot = getPluginRoot();
|
||||
if (pluginRoot) {
|
||||
const pluginPaths: string[] = [];
|
||||
if (contextName) {
|
||||
pluginPaths.push(path.join(pluginRoot, 'context', `${agentName}-${contextName}-${stage}.md`));
|
||||
}
|
||||
pluginPaths.push(path.join(pluginRoot, 'context', `${agentName}-${stage}.md`));
|
||||
|
||||
for (const filePath of pluginPaths) {
|
||||
if (await fileExists(filePath)) {
|
||||
await logger.debug('Found plugin agent context file', { path: filePath, agent: agentName, stage });
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compat: try standard discovery with command/skill name
|
||||
// (discoverContextFile already checks both project and plugin)
|
||||
if (contextName) {
|
||||
const standardPath = await discoverContextFile(cwd, contextName, stage);
|
||||
if (standardPath) {
|
||||
return standardPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract name and stage from hook event.
|
||||
* Returns { name, stage } for context file discovery.
|
||||
*
|
||||
* Mapping:
|
||||
* - SlashCommandStart → { name: command, stage: 'start' }
|
||||
* - SlashCommandEnd → { name: command, stage: 'end' }
|
||||
* - SkillStart → { name: skill, stage: 'start' }
|
||||
* - SkillEnd → { name: skill, stage: 'end' }
|
||||
* - PreToolUse → { name: tool_name, stage: 'pre' }
|
||||
* - PostToolUse → { name: tool_name, stage: 'post' }
|
||||
* - SubagentStop → { name: agent_name, stage: 'end' } (special handling)
|
||||
* - UserPromptSubmit → { name: 'prompt', stage: 'submit' }
|
||||
* - Stop → { name: 'agent', stage: 'stop' }
|
||||
* - SessionStart → { name: 'session', stage: 'start' }
|
||||
* - SessionEnd → { name: 'session', stage: 'end' }
|
||||
* - Notification → { name: 'notification', stage: 'receive' }
|
||||
*/
|
||||
function extractNameAndStage(
|
||||
hookEvent: string,
|
||||
input: HookInput
|
||||
): { name: string; stage: string } | null {
|
||||
switch (hookEvent) {
|
||||
case 'SlashCommandStart':
|
||||
return input.command
|
||||
? { name: input.command.replace(/^\//, '').replace(/^[^:]+:/, ''), stage: 'start' }
|
||||
: null;
|
||||
|
||||
case 'SlashCommandEnd':
|
||||
return input.command
|
||||
? { name: input.command.replace(/^\//, '').replace(/^[^:]+:/, ''), stage: 'end' }
|
||||
: null;
|
||||
|
||||
case 'SkillStart':
|
||||
return input.skill ? { name: input.skill.replace(/^[^:]+:/, ''), stage: 'start' } : null;
|
||||
|
||||
case 'SkillEnd':
|
||||
return input.skill ? { name: input.skill.replace(/^[^:]+:/, ''), stage: 'end' } : null;
|
||||
|
||||
case 'PreToolUse':
|
||||
return input.tool_name ? { name: input.tool_name.toLowerCase(), stage: 'pre' } : null;
|
||||
|
||||
case 'PostToolUse':
|
||||
return input.tool_name ? { name: input.tool_name.toLowerCase(), stage: 'post' } : null;
|
||||
|
||||
case 'SubagentStop':
|
||||
// SubagentStop has special handling - uses agent-command scoping
|
||||
return null;
|
||||
|
||||
case 'UserPromptSubmit':
|
||||
return { name: 'prompt', stage: 'submit' };
|
||||
|
||||
case 'Stop':
|
||||
return { name: 'agent', stage: 'stop' };
|
||||
|
||||
case 'SessionStart':
|
||||
return { name: 'session', stage: 'start' };
|
||||
|
||||
case 'SessionEnd':
|
||||
return { name: 'session', stage: 'end' };
|
||||
|
||||
case 'Notification':
|
||||
return { name: 'notification', stage: 'receive' };
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject context from .claude/context/ files based on hook event.
|
||||
* This is the PRIMARY built-in gate - automatic context injection.
|
||||
*
|
||||
* Convention:
|
||||
* - .claude/context/{name}-{stage}.md
|
||||
* - e.g., .claude/context/code-review-start.md
|
||||
* - e.g., .claude/context/prompt-submit.md
|
||||
*/
|
||||
export async function injectContext(hookEvent: string, input: HookInput): Promise<string | null> {
|
||||
await logger.debug('Context injection starting', { event: hookEvent, cwd: input.cwd });
|
||||
|
||||
// Handle SubagentStop with agent-command scoping (special case)
|
||||
if (hookEvent === 'SubagentStop' && input.agent_name) {
|
||||
const session = new Session(input.cwd);
|
||||
const activeCommand = await session.get('active_command');
|
||||
const activeSkill = await session.get('active_skill');
|
||||
const commandOrSkill = activeCommand || activeSkill;
|
||||
|
||||
const contextFile = await discoverAgentCommandContext(
|
||||
input.cwd,
|
||||
input.agent_name,
|
||||
commandOrSkill,
|
||||
'end'
|
||||
);
|
||||
|
||||
if (contextFile) {
|
||||
const content = await fs.readFile(contextFile, 'utf-8');
|
||||
await logger.info('Injecting agent context', {
|
||||
event: hookEvent,
|
||||
agent: input.agent_name,
|
||||
file: contextFile
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard context discovery for all other hooks
|
||||
const extracted = extractNameAndStage(hookEvent, input);
|
||||
if (!extracted) {
|
||||
await logger.debug('No name/stage extracted', { event: hookEvent });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, stage } = extracted;
|
||||
const contextFile = await discoverContextFile(input.cwd, name, stage);
|
||||
|
||||
if (contextFile) {
|
||||
const content = await fs.readFile(contextFile, 'utf-8');
|
||||
await logger.info('Injecting context', {
|
||||
event: hookEvent,
|
||||
name,
|
||||
stage,
|
||||
file: contextFile
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
await logger.debug('No context file found', { event: hookEvent, name, stage });
|
||||
return null;
|
||||
}
|
||||
260
hooks/hooks-app/src/dispatcher.ts
Normal file
260
hooks/hooks-app/src/dispatcher.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// plugin/hooks/hooks-app/src/dispatcher.ts
|
||||
import { HookInput, HookConfig, GateConfig } from './types';
|
||||
import { loadConfig } from './config';
|
||||
import { injectContext } from './context';
|
||||
import { executeGate } from './gate-loader';
|
||||
import { handleAction } from './action-handler';
|
||||
import { Session } from './session';
|
||||
import { logger } from './logger';
|
||||
|
||||
export function shouldProcessHook(input: HookInput, hookConfig: HookConfig): boolean {
|
||||
const hookEvent = input.hook_event_name;
|
||||
|
||||
// PostToolUse filtering
|
||||
if (hookEvent === 'PostToolUse') {
|
||||
if (hookConfig.enabled_tools && hookConfig.enabled_tools.length > 0) {
|
||||
return hookConfig.enabled_tools.includes(input.tool_name || '');
|
||||
}
|
||||
}
|
||||
|
||||
// SubagentStop filtering
|
||||
if (hookEvent === 'SubagentStop') {
|
||||
if (hookConfig.enabled_agents && hookConfig.enabled_agents.length > 0) {
|
||||
const agentName = input.agent_name || input.subagent_name || '';
|
||||
return hookConfig.enabled_agents.includes(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
// No filtering or other events
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DispatchResult {
|
||||
context?: string;
|
||||
blockReason?: string;
|
||||
stopMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ERROR HANDLING: Circular gate chain prevention (max 10 gates per dispatch).
|
||||
* Prevents infinite loops from misconfigured gate chains.
|
||||
*/
|
||||
const MAX_GATES_PER_DISPATCH = 10;
|
||||
|
||||
// Built-in gates removed - context injection is the primary behavior
|
||||
// Context injection happens via injectContext() which discovers .claude/context/ files
|
||||
|
||||
/**
|
||||
* Check if gate should run based on keyword matching (UserPromptSubmit only).
|
||||
* Gates without keywords always run (backwards compatible).
|
||||
*
|
||||
* Note: Uses substring matching, not word-boundary matching. This means "test"
|
||||
* will match "latest" or "contest". This is intentional for flexibility - users
|
||||
* can say "let's test this" or "testing the feature" and both will match.
|
||||
* If word-boundary matching is needed in the future, consider using regex like:
|
||||
* /\b${keyword}\b/i.test(message)
|
||||
*/
|
||||
export function gateMatchesKeywords(gateConfig: GateConfig, userMessage: string | undefined): boolean {
|
||||
// No keywords = always run (backwards compatible)
|
||||
if (!gateConfig.keywords || gateConfig.keywords.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No user message = skip keyword gates
|
||||
if (!userMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerMessage = userMessage.toLowerCase();
|
||||
return gateConfig.keywords.some(keyword =>
|
||||
lowerMessage.includes(keyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function updateSessionState(input: HookInput): Promise<void> {
|
||||
const session = new Session(input.cwd);
|
||||
const event = input.hook_event_name;
|
||||
|
||||
try {
|
||||
switch (event) {
|
||||
case 'SlashCommandStart':
|
||||
if (input.command) {
|
||||
await session.set('active_command', input.command);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SlashCommandEnd':
|
||||
await session.set('active_command', null);
|
||||
break;
|
||||
|
||||
case 'SkillStart':
|
||||
if (input.skill) {
|
||||
await session.set('active_skill', input.skill);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SkillEnd':
|
||||
await session.set('active_skill', null);
|
||||
break;
|
||||
|
||||
// Note: SubagentStart/SubagentStop NOT tracked - Claude Code does not
|
||||
// provide unique agent identifiers, making reliable agent tracking impossible
|
||||
// when multiple agents of the same type run in parallel.
|
||||
|
||||
case 'PostToolUse':
|
||||
if (input.file_path) {
|
||||
await session.append('edited_files', input.file_path);
|
||||
|
||||
// Extract and track file extension
|
||||
// Edge case: ext !== input.file_path prevents tracking entire filename
|
||||
// as extension when file has no dot (e.g., "README")
|
||||
const ext = input.file_path.split('.').pop();
|
||||
if (ext && ext !== input.file_path) {
|
||||
await session.append('file_extensions', ext);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Session state is best-effort, don't fail the hook if it errors
|
||||
// Structured error logging for debugging
|
||||
const errorData = {
|
||||
error_type: error instanceof Error ? error.constructor.name : 'UnknownError',
|
||||
error_message: error instanceof Error ? error.message : String(error),
|
||||
hook_event: event,
|
||||
cwd: input.cwd,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
console.error(`[Session Error] ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatch(input: HookInput): Promise<DispatchResult> {
|
||||
const hookEvent = input.hook_event_name;
|
||||
const cwd = input.cwd;
|
||||
const startTime = Date.now();
|
||||
|
||||
await logger.event('debug', hookEvent, {
|
||||
tool: input.tool_name,
|
||||
agent: input.agent_name || input.subagent_name,
|
||||
file: input.file_path,
|
||||
cwd,
|
||||
});
|
||||
|
||||
// Update session state (best-effort)
|
||||
await updateSessionState(input);
|
||||
|
||||
// 1. ALWAYS run context injection FIRST (primary behavior)
|
||||
// This discovers .claude/context/{name}-{stage}.md files
|
||||
const contextContent = await injectContext(hookEvent, input);
|
||||
let accumulatedContext = contextContent || '';
|
||||
|
||||
// 2. Load config for additional gates (optional)
|
||||
const config = await loadConfig(cwd);
|
||||
if (!config) {
|
||||
await logger.debug('No gates.json config found', { cwd });
|
||||
// Return context injection result even without gates.json
|
||||
return accumulatedContext ? { context: accumulatedContext } : {};
|
||||
}
|
||||
|
||||
// 3. Check if hook event has additional gates configured
|
||||
const hookConfig = config.hooks[hookEvent];
|
||||
if (!hookConfig) {
|
||||
await logger.debug('Hook event not configured in gates.json', { event: hookEvent });
|
||||
// Return context injection result even if hook not in gates.json
|
||||
return accumulatedContext ? { context: accumulatedContext } : {};
|
||||
}
|
||||
|
||||
// 4. Filter by enabled lists
|
||||
if (!shouldProcessHook(input, hookConfig)) {
|
||||
await logger.debug('Hook filtered out by enabled list', {
|
||||
event: hookEvent,
|
||||
tool: input.tool_name,
|
||||
agent: input.agent_name,
|
||||
});
|
||||
// Still return context injection result
|
||||
return accumulatedContext ? { context: accumulatedContext } : {};
|
||||
}
|
||||
|
||||
// 5. Run additional gates in sequence (from gates.json)
|
||||
const gates = hookConfig.gates || [];
|
||||
let gatesExecuted = 0;
|
||||
|
||||
for (let i = 0; i < gates.length; i++) {
|
||||
const gateName = gates[i];
|
||||
|
||||
// Circuit breaker: prevent infinite chains
|
||||
if (gatesExecuted >= MAX_GATES_PER_DISPATCH) {
|
||||
return {
|
||||
blockReason: `Exceeded max gate chain depth (${MAX_GATES_PER_DISPATCH}). Check for circular references.`
|
||||
};
|
||||
}
|
||||
|
||||
const gateConfig = config.gates[gateName];
|
||||
if (!gateConfig) {
|
||||
// Graceful degradation: skip undefined gates with warning
|
||||
accumulatedContext += `\nWarning: Gate '${gateName}' not defined, skipping`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keyword filtering for UserPromptSubmit
|
||||
if (hookEvent === 'UserPromptSubmit' && !gateMatchesKeywords(gateConfig, input.user_message)) {
|
||||
await logger.debug('Gate skipped - no keyword match', { gate: gateName });
|
||||
continue;
|
||||
}
|
||||
|
||||
gatesExecuted++;
|
||||
|
||||
// Execute gate
|
||||
const gateStartTime = Date.now();
|
||||
const { passed, result } = await executeGate(gateName, gateConfig, input, []);
|
||||
const gateDuration = Date.now() - gateStartTime;
|
||||
|
||||
await logger.event('info', hookEvent, {
|
||||
gate: gateName,
|
||||
passed,
|
||||
duration_ms: gateDuration,
|
||||
tool: input.tool_name,
|
||||
});
|
||||
|
||||
// Determine action
|
||||
const action = passed ? gateConfig.on_pass || 'CONTINUE' : gateConfig.on_fail || 'BLOCK';
|
||||
|
||||
// Handle action
|
||||
const actionResult = await handleAction(action, result, config, input);
|
||||
|
||||
if (actionResult.context) {
|
||||
accumulatedContext += '\n' + actionResult.context;
|
||||
}
|
||||
|
||||
if (!actionResult.continue) {
|
||||
await logger.event('warn', hookEvent, {
|
||||
gate: gateName,
|
||||
action,
|
||||
blocked: !!actionResult.blockReason,
|
||||
stopped: !!actionResult.stopMessage,
|
||||
duration_ms: Date.now() - startTime,
|
||||
});
|
||||
return {
|
||||
context: accumulatedContext,
|
||||
blockReason: actionResult.blockReason,
|
||||
stopMessage: actionResult.stopMessage
|
||||
};
|
||||
}
|
||||
|
||||
// Gate chaining
|
||||
if (actionResult.chainedGate) {
|
||||
gates.push(actionResult.chainedGate);
|
||||
}
|
||||
}
|
||||
|
||||
await logger.event('debug', hookEvent, {
|
||||
status: 'completed',
|
||||
gates_executed: gatesExecuted,
|
||||
duration_ms: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
context: accumulatedContext
|
||||
};
|
||||
}
|
||||
210
hooks/hooks-app/src/gate-loader.ts
Normal file
210
hooks/hooks-app/src/gate-loader.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// plugin/hooks/hooks-app/src/gate-loader.ts
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
import { HookInput, GateResult, GateConfig, GatesConfig } from './types';
|
||||
import { resolvePluginPath, loadConfigFile } from './config';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ShellResult {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute shell command from gate configuration with timeout.
|
||||
*
|
||||
* SECURITY MODEL: gates.json is trusted configuration (project-controlled, not user input).
|
||||
* Commands are executed without sanitization because:
|
||||
* 1. gates.json is committed to repository or managed by project admins
|
||||
* 2. Users cannot inject commands without write access to gates.json
|
||||
* 3. If gates.json is compromised, the project is already compromised
|
||||
*
|
||||
* This is equivalent to package.json scripts or Makefile targets - trusted project configuration.
|
||||
*
|
||||
* ERROR HANDLING: Commands timeout after 30 seconds to prevent hung gates.
|
||||
*/
|
||||
export async function executeShellCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
timeoutMs: number = 30000
|
||||
): Promise<ShellResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, { cwd, timeout: timeoutMs });
|
||||
return {
|
||||
exitCode: 0,
|
||||
output: stdout + stderr
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
if (err.killed && err.signal === 'SIGTERM') {
|
||||
return {
|
||||
exitCode: 124, // Standard timeout exit code
|
||||
output: `Command timed out after ${timeoutMs}ms`
|
||||
};
|
||||
}
|
||||
return {
|
||||
exitCode: err.code || 1,
|
||||
output: (err.stdout || '') + (err.stderr || '')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and execute a built-in TypeScript gate
|
||||
*
|
||||
* Built-in gates are TypeScript modules in src/gates/ that export an execute function.
|
||||
* Gate names use kebab-case and are mapped to camelCase module names:
|
||||
* - "plugin-path" → pluginPath
|
||||
* - "custom-gate" → customGate
|
||||
*/
|
||||
export async function executeBuiltinGate(gateName: string, input: HookInput): Promise<GateResult> {
|
||||
try {
|
||||
// Convert kebab-case to camelCase for module lookup
|
||||
// "plugin-path" -> "pluginPath"
|
||||
const moduleName = gateName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
// Import the gate module dynamically
|
||||
const gates = await import('./gates');
|
||||
const gateModule = (gates as any)[moduleName];
|
||||
|
||||
if (!gateModule || typeof gateModule.execute !== 'function') {
|
||||
throw new Error(`Gate module '${moduleName}' not found or missing execute function`);
|
||||
}
|
||||
|
||||
return await gateModule.execute(input);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load built-in gate ${gateName}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track plugin gate call stack to detect circular references
|
||||
const MAX_PLUGIN_DEPTH = 10;
|
||||
|
||||
export async function executeGate(
|
||||
gateName: string,
|
||||
gateConfig: GateConfig,
|
||||
input: HookInput,
|
||||
pluginStack: string[] = []
|
||||
): Promise<{ passed: boolean; result: GateResult }> {
|
||||
// Handle plugin gate reference
|
||||
if (gateConfig.plugin && gateConfig.gate) {
|
||||
// Circular reference detection
|
||||
const gateRef = `${gateConfig.plugin}:${gateConfig.gate}`;
|
||||
if (pluginStack.includes(gateRef)) {
|
||||
throw new Error(
|
||||
`Circular gate reference detected: ${pluginStack.join(' -> ')} -> ${gateRef}`
|
||||
);
|
||||
}
|
||||
|
||||
// Depth limit to prevent infinite recursion
|
||||
if (pluginStack.length >= MAX_PLUGIN_DEPTH) {
|
||||
throw new Error(
|
||||
`Maximum plugin gate depth (${MAX_PLUGIN_DEPTH}) exceeded: ${pluginStack.join(' -> ')} -> ${gateRef}`
|
||||
);
|
||||
}
|
||||
|
||||
const { gateConfig: pluginGateConfig, pluginRoot } = await loadPluginGate(
|
||||
gateConfig.plugin,
|
||||
gateConfig.gate
|
||||
);
|
||||
|
||||
// Recursively execute the plugin's gate with updated stack
|
||||
const newStack = [...pluginStack, gateRef];
|
||||
|
||||
// Execute the plugin's gate command in the plugin's directory
|
||||
if (pluginGateConfig.command) {
|
||||
const shellResult = await executeShellCommand(pluginGateConfig.command, pluginRoot);
|
||||
const passed = shellResult.exitCode === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
result: {
|
||||
additionalContext: shellResult.output
|
||||
}
|
||||
};
|
||||
} else if (pluginGateConfig.plugin && pluginGateConfig.gate) {
|
||||
// Plugin gate references another plugin gate - recurse
|
||||
return executeGate(gateRef, pluginGateConfig, input, newStack);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Plugin gate '${gateConfig.plugin}:${gateConfig.gate}' has no command`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (gateConfig.command) {
|
||||
// Shell command gate (existing behavior)
|
||||
const shellResult = await executeShellCommand(gateConfig.command, input.cwd);
|
||||
const passed = shellResult.exitCode === 0;
|
||||
|
||||
return {
|
||||
passed,
|
||||
result: {
|
||||
additionalContext: shellResult.output
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Built-in TypeScript gate
|
||||
const result = await executeBuiltinGate(gateName, input);
|
||||
const passed = !result.decision && result.continue !== false;
|
||||
|
||||
return {
|
||||
passed,
|
||||
result
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginGateResult {
|
||||
gateConfig: GateConfig;
|
||||
pluginRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a gate definition from another plugin.
|
||||
*
|
||||
* SECURITY: Plugins are trusted by virtue of being explicitly installed by the user.
|
||||
* This function loads plugin configuration and does NOT validate command safety.
|
||||
* The trust boundary is at plugin installation, not at gate reference.
|
||||
*
|
||||
* However, we do validate that the loaded config has the expected structure to
|
||||
* prevent runtime errors from malformed plugin configurations.
|
||||
*
|
||||
* @param pluginName - Name of the plugin (e.g., 'cipherpowers')
|
||||
* @param gateName - Name of the gate within the plugin
|
||||
* @returns The gate config and the plugin root path for execution context
|
||||
*/
|
||||
export async function loadPluginGate(
|
||||
pluginName: string,
|
||||
gateName: string
|
||||
): Promise<PluginGateResult> {
|
||||
const pluginRoot = resolvePluginPath(pluginName);
|
||||
const gatesPath = path.join(pluginRoot, 'hooks', 'gates.json');
|
||||
|
||||
const pluginConfig = await loadConfigFile(gatesPath);
|
||||
if (!pluginConfig) {
|
||||
throw new Error(`Cannot find gates.json for plugin '${pluginName}' at ${gatesPath}`);
|
||||
}
|
||||
|
||||
// Validate plugin config has gates object
|
||||
if (!pluginConfig.gates || typeof pluginConfig.gates !== 'object') {
|
||||
throw new Error(
|
||||
`Invalid gates.json structure in plugin '${pluginName}': missing or invalid 'gates' object`
|
||||
);
|
||||
}
|
||||
|
||||
const gateConfig = pluginConfig.gates[gateName];
|
||||
if (!gateConfig) {
|
||||
throw new Error(`Gate '${gateName}' not found in plugin '${pluginName}'`);
|
||||
}
|
||||
|
||||
return { gateConfig, pluginRoot };
|
||||
}
|
||||
8
hooks/hooks-app/src/gates/index.ts
Normal file
8
hooks/hooks-app/src/gates/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// plugin/hooks/hooks-app/src/gates/index.ts
|
||||
/**
|
||||
* Built-in gates registry
|
||||
*
|
||||
* All TypeScript gates are exported here for easy discovery and import.
|
||||
*/
|
||||
|
||||
export * as pluginPath from './plugin-path';
|
||||
54
hooks/hooks-app/src/gates/plugin-path.ts
Normal file
54
hooks/hooks-app/src/gates/plugin-path.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// plugin/hooks/hooks-app/src/gates/plugin-path.ts
|
||||
import { HookInput, GateResult } from '../types';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Plugin Path Injection Gate
|
||||
*
|
||||
* Injects CLAUDE_PLUGIN_ROOT as context for agents to resolve file references.
|
||||
* This gate provides the absolute path to the plugin root directory, enabling
|
||||
* agents to properly resolve @${CLAUDE_PLUGIN_ROOT}/... file references.
|
||||
*
|
||||
* Typical usage: SubagentStop hook to inject path context when agents complete.
|
||||
*/
|
||||
|
||||
export async function execute(_input: HookInput): Promise<GateResult> {
|
||||
// Determine plugin root:
|
||||
// 1. Use CLAUDE_PLUGIN_ROOT if set (standard Claude Code environment)
|
||||
// 2. Otherwise compute from this script's location
|
||||
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || computePluginRoot();
|
||||
|
||||
const contextMessage = `## Plugin Path Context
|
||||
|
||||
For this session:
|
||||
\`\`\`
|
||||
CLAUDE_PLUGIN_ROOT=${pluginRoot}
|
||||
\`\`\`
|
||||
|
||||
When you see file references like \`@\${CLAUDE_PLUGIN_ROOT}skills/...\`, resolve them using the path above.`;
|
||||
|
||||
return {
|
||||
additionalContext: contextMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute plugin root from this file's location
|
||||
* This file is at: plugin/hooks/hooks-app/src/gates/plugin-path.ts
|
||||
* Plugin root is: plugin/
|
||||
*
|
||||
* We go up 4 levels: gates/ -> src/ -> hooks-app/ -> hooks/ -> plugin/
|
||||
*/
|
||||
function computePluginRoot(): string {
|
||||
// In CommonJS, use __dirname
|
||||
// __dirname is at: plugin/hooks/hooks-app/dist/gates/
|
||||
// (after compilation from src/ to dist/)
|
||||
|
||||
// Go up 4 directories from dist/gates/
|
||||
let pluginRoot = path.dirname(__dirname); // dist/
|
||||
pluginRoot = path.dirname(pluginRoot); // hooks-app/
|
||||
pluginRoot = path.dirname(pluginRoot); // hooks/
|
||||
pluginRoot = path.dirname(pluginRoot); // plugin/
|
||||
|
||||
return pluginRoot;
|
||||
}
|
||||
25
hooks/hooks-app/src/index.ts
Normal file
25
hooks/hooks-app/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// plugin/hooks/hooks-app/src/index.ts
|
||||
|
||||
// Existing exports
|
||||
export { dispatch } from './dispatcher';
|
||||
export { executeGate } from './gate-loader';
|
||||
export { handleAction } from './action-handler';
|
||||
export { loadConfig } from './config';
|
||||
export { injectContext } from './context';
|
||||
|
||||
export type {
|
||||
HookInput,
|
||||
GateResult,
|
||||
GateExecute,
|
||||
GateConfig,
|
||||
HookConfig,
|
||||
GatesConfig
|
||||
} from './types';
|
||||
|
||||
// New session exports
|
||||
export { Session } from './session';
|
||||
export type { SessionState, SessionStateArrayKey, SessionStateScalarKey } from './types';
|
||||
|
||||
// Logging exports
|
||||
export { logger } from './logger';
|
||||
export type { LogLevel } from './logger';
|
||||
180
hooks/hooks-app/src/logger.ts
Normal file
180
hooks/hooks-app/src/logger.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// plugin/hooks/hooks-app/src/logger.ts
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogEntry {
|
||||
ts: string;
|
||||
level: LogLevel;
|
||||
event?: string;
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the log directory path.
|
||||
* Uses ${TMPDIR}/turboshovel/ for isolation.
|
||||
*/
|
||||
function getLogDir(): string {
|
||||
return path.join(tmpdir(), 'turboshovel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the log file path for today.
|
||||
* Format: hooks-YYYY-MM-DD.log
|
||||
*/
|
||||
function getLogFilePath(): string {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
return path.join(getLogDir(), `hooks-${date}.log`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled via environment variable.
|
||||
* Logging is ENABLED by default (env vars don't pass through from Claude CLI).
|
||||
* Set TURBOSHOVEL_LOG=0 to disable.
|
||||
*/
|
||||
function isLoggingEnabled(): boolean {
|
||||
return process.env.TURBOSHOVEL_LOG !== '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum log level from environment.
|
||||
* TURBOSHOVEL_LOG_LEVEL=debug|info|warn|error (default: info)
|
||||
*/
|
||||
function getMinLogLevel(): LogLevel {
|
||||
const level = process.env.TURBOSHOVEL_LOG_LEVEL as LogLevel;
|
||||
if (level && LOG_LEVELS[level] !== undefined) {
|
||||
return level;
|
||||
}
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log level should be written based on minimum level.
|
||||
*/
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[getMinLogLevel()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the log directory exists.
|
||||
*/
|
||||
async function ensureLogDir(): Promise<void> {
|
||||
const dir = getLogDir();
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry to the log file.
|
||||
* Each entry is a JSON line for easy parsing with jq.
|
||||
*/
|
||||
async function writeLog(entry: LogEntry): Promise<void> {
|
||||
if (!isLoggingEnabled()) return;
|
||||
if (!shouldLog(entry.level)) return;
|
||||
|
||||
try {
|
||||
await ensureLogDir();
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
await fs.appendFile(getLogFilePath(), line, 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - logging should never break the hook
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry unconditionally (bypasses TURBOSHOVEL_LOG check).
|
||||
* Used for startup/diagnostic logging to verify hooks are being invoked.
|
||||
*/
|
||||
async function writeLogAlways(entry: LogEntry): Promise<void> {
|
||||
try {
|
||||
await ensureLogDir();
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
await fs.appendFile(getLogFilePath(), line, 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - logging should never break the hook
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a log entry with timestamp.
|
||||
*/
|
||||
function createEntry(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): LogEntry {
|
||||
return {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for hooks-app.
|
||||
*
|
||||
* Enable logging: TURBOSHOVEL_LOG=1
|
||||
* Set level: TURBOSHOVEL_LOG_LEVEL=debug|info|warn|error
|
||||
*
|
||||
* Logs are written to: ${TMPDIR}/turboshovel/hooks-YYYY-MM-DD.log
|
||||
* Format: JSON lines (one JSON object per line)
|
||||
*
|
||||
* Example:
|
||||
* {"ts":"2025-11-25T10:30:00.000Z","level":"info","event":"PostToolUse","tool":"Edit"}
|
||||
*/
|
||||
export const logger = {
|
||||
debug: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('debug', message, data)),
|
||||
|
||||
info: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('info', message, data)),
|
||||
|
||||
warn: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('warn', message, data)),
|
||||
|
||||
error: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLog(createEntry('error', message, data)),
|
||||
|
||||
/**
|
||||
* Log unconditionally (bypasses TURBOSHOVEL_LOG check).
|
||||
* Used for startup/diagnostic logging to verify hooks are invoked.
|
||||
*/
|
||||
always: (message: string, data?: Record<string, unknown>) =>
|
||||
writeLogAlways(createEntry('info', message, data)),
|
||||
|
||||
/**
|
||||
* Log a hook event with structured data.
|
||||
* Convenience method for common hook logging pattern.
|
||||
*/
|
||||
event: (
|
||||
level: LogLevel,
|
||||
event: string,
|
||||
data?: Record<string, unknown>
|
||||
) =>
|
||||
writeLog({
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
event,
|
||||
...data,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the current log file path (for mise tasks).
|
||||
*/
|
||||
getLogFilePath,
|
||||
|
||||
/**
|
||||
* Get the log directory path (for mise tasks).
|
||||
*/
|
||||
getLogDir,
|
||||
};
|
||||
131
hooks/hooks-app/src/session.ts
Normal file
131
hooks/hooks-app/src/session.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { SessionState, SessionStateArrayKey } from './types';
|
||||
|
||||
/**
|
||||
* Manages session state with atomic file updates.
|
||||
*
|
||||
* State is stored in .claude/session/state.json relative to the project directory.
|
||||
*/
|
||||
export class Session {
|
||||
private stateFile: string;
|
||||
|
||||
constructor(cwd: string = '.') {
|
||||
this.stateFile = join(cwd, '.claude', 'session', 'state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session state value
|
||||
*/
|
||||
async get<K extends keyof SessionState>(key: K): Promise<SessionState[K]> {
|
||||
const state = await this.load();
|
||||
return state[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a session state value
|
||||
*/
|
||||
async set<K extends keyof SessionState>(key: K, value: SessionState[K]): Promise<void> {
|
||||
const state = await this.load();
|
||||
state[key] = value;
|
||||
await this.save(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append value to array field (deduplicated)
|
||||
*/
|
||||
async append(key: SessionStateArrayKey, value: string): Promise<void> {
|
||||
const state = await this.load();
|
||||
const array = state[key];
|
||||
|
||||
if (!array.includes(value)) {
|
||||
array.push(value);
|
||||
await this.save(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if array contains value
|
||||
*/
|
||||
async contains(key: SessionStateArrayKey, value: string): Promise<boolean> {
|
||||
const state = await this.load();
|
||||
return state[key].includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session state (remove file)
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.stateFile);
|
||||
} catch (error) {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from file or initialize new state
|
||||
*/
|
||||
private async load(): Promise<SessionState> {
|
||||
try {
|
||||
const content = await fs.readFile(this.stateFile, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
// File doesn't exist or is corrupt, initialize new state
|
||||
return this.initState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state to file atomically (write to temp, then rename)
|
||||
*
|
||||
* Performance note: File I/O adds small overhead (~1-5ms) per operation.
|
||||
* Atomic writes prevent corruption but require temp file creation.
|
||||
*
|
||||
* Concurrency note: Atomic rename prevents file corruption (invalid JSON,
|
||||
* partial writes) but does NOT prevent logical race conditions where
|
||||
* concurrent operations overwrite each other's changes. This is acceptable
|
||||
* because hooks run sequentially in practice. If true concurrent access is
|
||||
* needed, add file locking or retry logic.
|
||||
*/
|
||||
private async save(state: SessionState): Promise<void> {
|
||||
await fs.mkdir(dirname(this.stateFile), { recursive: true });
|
||||
const temp = this.stateFile + '.tmp';
|
||||
|
||||
try {
|
||||
// Write to temp file
|
||||
await fs.writeFile(temp, JSON.stringify(state, null, 2), 'utf-8');
|
||||
|
||||
// Atomic rename (prevents corruption from concurrent writes)
|
||||
await fs.rename(temp, this.stateFile);
|
||||
} catch (error) {
|
||||
// Clean up temp file on error
|
||||
try {
|
||||
await fs.unlink(temp);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize new session state
|
||||
*
|
||||
* Session ID format: ISO timestamp with punctuation replaced (e.g., "2025-11-23T14-30-45")
|
||||
* Unique per millisecond. Collisions possible if multiple sessions start in same millisecond,
|
||||
* but unlikely in practice due to hook serialization.
|
||||
*/
|
||||
private initState(): SessionState {
|
||||
const now = new Date();
|
||||
return {
|
||||
session_id: now.toISOString().replace(/[:.]/g, '-').substring(0, 19),
|
||||
started_at: now.toISOString(),
|
||||
active_command: null,
|
||||
active_skill: null,
|
||||
edited_files: [],
|
||||
file_extensions: [],
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
102
hooks/hooks-app/src/types.ts
Normal file
102
hooks/hooks-app/src/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// plugin/hooks/hooks-app/src/types.ts
|
||||
|
||||
export interface HookInput {
|
||||
hook_event_name: string;
|
||||
cwd: string;
|
||||
|
||||
// PostToolUse
|
||||
tool_name?: string;
|
||||
file_path?: string;
|
||||
|
||||
// SubagentStop
|
||||
agent_name?: string;
|
||||
subagent_name?: string;
|
||||
output?: string;
|
||||
|
||||
// UserPromptSubmit
|
||||
user_message?: string;
|
||||
|
||||
// SlashCommand/Skill
|
||||
command?: string;
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
export interface GateResult {
|
||||
// Success - add context and continue
|
||||
additionalContext?: string;
|
||||
|
||||
// Block agent from proceeding
|
||||
decision?: 'block';
|
||||
reason?: string;
|
||||
|
||||
// Stop Claude entirely
|
||||
continue?: false;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type GateExecute = (input: HookInput) => Promise<GateResult>;
|
||||
|
||||
export interface GateConfig {
|
||||
/** Reference gate from another plugin (requires gate field) */
|
||||
plugin?: string;
|
||||
|
||||
/** Gate name within the plugin's hooks/gates.json (requires plugin field) */
|
||||
gate?: string;
|
||||
|
||||
/** Local shell command (mutually exclusive with plugin/gate) */
|
||||
command?: string;
|
||||
|
||||
/**
|
||||
* Keywords that trigger this gate (UserPromptSubmit hook only).
|
||||
* When specified, the gate only runs if the user message contains one of these keywords.
|
||||
* For all other hooks (PostToolUse, SubagentStop, etc.), this field is ignored.
|
||||
* Gates without keywords always run (backwards compatible).
|
||||
*/
|
||||
keywords?: string[];
|
||||
on_pass?: string;
|
||||
on_fail?: string;
|
||||
}
|
||||
|
||||
export interface HookConfig {
|
||||
enabled_tools?: string[];
|
||||
enabled_agents?: string[];
|
||||
gates?: string[];
|
||||
}
|
||||
|
||||
export interface GatesConfig {
|
||||
hooks: Record<string, HookConfig>;
|
||||
gates: Record<string, GateConfig>;
|
||||
}
|
||||
|
||||
// Session state interface
|
||||
export interface SessionState {
|
||||
/** Unique session identifier (timestamp-based) */
|
||||
session_id: string;
|
||||
|
||||
/** ISO 8601 timestamp when session started */
|
||||
started_at: string;
|
||||
|
||||
/** Currently active slash command (e.g., "/execute") */
|
||||
active_command: string | null;
|
||||
|
||||
/** Currently active skill (e.g., "executing-plans") */
|
||||
active_skill: string | null;
|
||||
|
||||
/** Files edited during this session */
|
||||
edited_files: string[];
|
||||
|
||||
/** File extensions edited during this session (deduplicated) */
|
||||
file_extensions: string[];
|
||||
|
||||
/** Custom metadata for specific workflows */
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// Note: active_agent NOT included - Claude Code does not provide unique
|
||||
// agent identifiers. Use metadata field if you need custom agent tracking.
|
||||
|
||||
/** Array field keys in SessionState (for type-safe operations) */
|
||||
export type SessionStateArrayKey = 'edited_files' | 'file_extensions';
|
||||
|
||||
/** Scalar field keys in SessionState */
|
||||
export type SessionStateScalarKey = Exclude<keyof SessionState, SessionStateArrayKey | 'metadata'>;
|
||||
15
hooks/hooks-app/src/utils.ts
Normal file
15
hooks/hooks-app/src/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// plugin/hooks/hooks-app/src/utils.ts
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Check if a file exists at the given path.
|
||||
* Used by config and context modules to probe file system.
|
||||
*/
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
hooks/hooks-app/tsconfig.eslint.json
Normal file
5
hooks/hooks-app/tsconfig.eslint.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*", "__tests__/**/*"],
|
||||
"exclude": ["node_modules", "dist", "__tests__/**/*.d.ts"]
|
||||
}
|
||||
17
hooks/hooks-app/tsconfig.json
Normal file
17
hooks/hooks-app/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "__tests__"]
|
||||
}
|
||||
Reference in New Issue
Block a user