diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index f0aeb34..d3e1b2c 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -99,10 +99,11 @@ describe('doctor — hook checks', () => { }); it('should fail when a subcommand is missing from settings', async () => { - // Missing 'contribute-check' subcommand + // Missing 'hook-dispatch' subcommand (the only required one now) mockedReadFileSafe.mockImplementation(async (filePath: string) => { if (filePath.includes('settings.json')) { - return buildPartialHooksContent(['contribute-check']); + // Return settings without hook-dispatch + return '{ "hooks": { "command": "bash -lc \\"teamai pull\\"" } }'; } if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) { return '# [teamai:env:start]'; @@ -136,14 +137,8 @@ describe('doctor — hook checks', () => { }); it('should check all TEAMAI_HOOK_SUBCOMMANDS', () => { - // Verify the subcommands list is what we expect - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('pull'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('update'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track-slash'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('contribute-check'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('auto-recall'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(7); + // With the merged dispatch format, only hook-dispatch is needed + expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('hook-dispatch'); + expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(1); }); }); diff --git a/src/__tests__/hook-dispatch.test.ts b/src/__tests__/hook-dispatch.test.ts new file mode 100644 index 0000000..450a944 --- /dev/null +++ b/src/__tests__/hook-dispatch.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Test doubles ──────────────────────────────────────── + +/** Minimal handler interface for testing. */ +interface TestHandler { + name: string; + execute: ReturnType; +} + +function createHandler(name: string, output?: string): TestHandler { + return { + name, + execute: vi.fn().mockResolvedValue(output ?? null), + }; +} + +// ── Import after understanding module shape ───────────── + +import { + createDispatcher, + type HookHandler, + type DispatchResult, +} from '../hook-dispatch.js'; + +// ── Tests ─────────────────────────────────────────────── + +describe('hook-dispatch', () => { + describe('routing', () => { + it('dispatches to all handlers registered for the given event+matcher', async () => { + const pullHandler = createHandler('pull'); + const dashboardHandler = createHandler('dashboard-report'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: pullHandler }, + { event: 'session-start', matcher: '*', handler: dashboardHandler }, + { event: 'stop', matcher: '*', handler: createHandler('update') }, + ], + }); + + const stdin = { session_id: 'test-123', cwd: '/tmp' }; + await dispatcher.dispatch('session-start', '*', stdin, 'claude'); + + expect(pullHandler.execute).toHaveBeenCalledOnce(); + expect(dashboardHandler.execute).toHaveBeenCalledOnce(); + }); + + it('does not invoke handlers for a different event', async () => { + const stopHandler = createHandler('update'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: createHandler('pull') }, + { event: 'stop', matcher: '*', handler: stopHandler }, + ], + }); + + await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(stopHandler.execute).not.toHaveBeenCalled(); + }); + + it('does not invoke handlers with a different matcher', async () => { + const skillHandler = createHandler('track'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'post-tool-use', matcher: '*', handler: createHandler('dashboard') }, + { event: 'post-tool-use', matcher: 'Skill', handler: skillHandler }, + ], + }); + + await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude'); + + expect(skillHandler.execute).not.toHaveBeenCalled(); + }); + + it('wildcard matcher handlers also fire when a specific matcher is dispatched', async () => { + const wildcardHandler = createHandler('dashboard'); + const bashHandler = createHandler('auto-recall'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'post-tool-use', matcher: '*', handler: wildcardHandler }, + { event: 'post-tool-use', matcher: 'Bash', handler: bashHandler }, + ], + }); + + await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude'); + + expect(wildcardHandler.execute).toHaveBeenCalledOnce(); + expect(bashHandler.execute).toHaveBeenCalledOnce(); + }); + }); + + describe('isolation', () => { + it('a failing handler does not prevent other handlers from executing', async () => { + const failingHandler = createHandler('failing'); + failingHandler.execute.mockRejectedValue(new Error('boom')); + const successHandler = createHandler('success'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: failingHandler }, + { event: 'session-start', matcher: '*', handler: successHandler }, + ], + }); + + await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(successHandler.execute).toHaveBeenCalledOnce(); + }); + + it('returns errors from failed handlers in the result', async () => { + const failingHandler = createHandler('failing'); + failingHandler.execute.mockRejectedValue(new Error('boom')); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: failingHandler }, + { event: 'session-start', matcher: '*', handler: createHandler('ok') }, + ], + }); + + const result = await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].handlerName).toBe('failing'); + expect(result.errors[0].error.message).toBe('boom'); + }); + }); + + describe('output merging', () => { + it('returns output from the handler that produces one', async () => { + const outputHandler = createHandler('auto-recall', '{"hookSpecificOutput":{"additionalContext":"found stuff"}}'); + const silentHandler = createHandler('dashboard'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'post-tool-use', matcher: 'Bash', handler: outputHandler }, + { event: 'post-tool-use', matcher: '*', handler: silentHandler }, + ], + }); + + const result = await dispatcher.dispatch('post-tool-use', 'Bash', {}, 'claude'); + + expect(result.output).toBe('{"hookSpecificOutput":{"additionalContext":"found stuff"}}'); + }); + + it('returns null output when no handler produces output', async () => { + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: createHandler('pull') }, + { event: 'session-start', matcher: '*', handler: createHandler('dashboard') }, + ], + }); + + const result = await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(result.output).toBeNull(); + }); + }); + + describe('stdin sharing', () => { + it('passes the same stdin object to all handlers', async () => { + const handler1 = createHandler('h1'); + const handler2 = createHandler('h2'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'stop', matcher: '*', handler: handler1 }, + { event: 'stop', matcher: '*', handler: handler2 }, + ], + }); + + const stdin = { session_id: 'abc', cwd: '/project' }; + await dispatcher.dispatch('stop', '*', stdin, 'claude'); + + expect(handler1.execute).toHaveBeenCalledWith(stdin, 'claude'); + expect(handler2.execute).toHaveBeenCalledWith(stdin, 'claude'); + }); + }); + + describe('timeout', () => { + it('aborts a handler that exceeds its timeout', async () => { + const slowHandler: TestHandler = { + name: 'slow', + execute: vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve('late'), 5000)), + ), + }; + const fastHandler = createHandler('fast', 'quick'); + + const dispatcher = createDispatcher({ + handlers: [ + { event: 'session-start', matcher: '*', handler: slowHandler, timeoutMs: 50 }, + { event: 'session-start', matcher: '*', handler: fastHandler }, + ], + }); + + const result = await dispatcher.dispatch('session-start', '*', {}, 'claude'); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].handlerName).toBe('slow'); + expect(result.errors[0].error.message).toContain('timeout'); + expect(result.output).toBe('quick'); + }); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts new file mode 100644 index 0000000..01527a6 --- /dev/null +++ b/src/__tests__/hook-handlers.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────── +// Mock the underlying modules so handlers don't do real I/O + +const mockPull = vi.fn().mockResolvedValue(undefined); +const mockDashboardReport = vi.fn().mockResolvedValue(undefined); +const mockParseHookEvent = vi.fn().mockResolvedValue({ type: 'session_start', timestamp: '2026-01-01', sessionId: 'test', tool: 'claude' }); +const mockAppendEvent = vi.fn().mockResolvedValue(undefined); +const mockTrackFromParsed = vi.fn().mockResolvedValue(undefined); +const mockTrackSlashFromParsed = vi.fn().mockResolvedValue(undefined); +const mockAutoRecallFromParsed = vi.fn().mockResolvedValue(null); +const mockContributeCheckForSession = vi.fn().mockResolvedValue({ hint: null }); +const mockDoUpdate = vi.fn().mockResolvedValue(undefined); + +vi.mock('../pull.js', () => ({ + pull: mockPull, +})); + +vi.mock('../dashboard-collector.js', () => ({ + parseHookEvent: mockParseHookEvent, + appendEvent: mockAppendEvent, + compactEvents: vi.fn().mockResolvedValue(undefined), + dashboardReport: mockDashboardReport, +})); + +vi.mock('../usage-tracker.js', () => ({ + trackFromStdin: mockTrackFromParsed, + trackSlashCommand: mockTrackSlashFromParsed, + extractSkillName: vi.fn(), + isValidSkillName: vi.fn().mockReturnValue(true), + appendUsageEvent: vi.fn().mockResolvedValue(undefined), + updateKnownSkills: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../auto-recall.js', () => ({ + autoRecall: mockAutoRecallFromParsed, +})); + +vi.mock('../contribute-check.js', () => ({ + contributeCheck: vi.fn().mockResolvedValue(undefined), + contributeCheckForSession: mockContributeCheckForSession, +})); + +vi.mock('../update.js', () => ({ + doUpdate: mockDoUpdate, + checkForUpdate: vi.fn().mockResolvedValue({ available: false, current: '1.0.0' }), +})); + +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn().mockResolvedValue({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {} }, + }), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { buildHandlerRegistry, type HandlerRegistration } from '../hook-handlers.js'; + +// ── Tests ──────────────────────────────────────────────── + +describe('hook-handlers registry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns registrations for all expected events', () => { + const registry = buildHandlerRegistry(); + const events = new Set(registry.map((r) => r.event)); + expect(events).toContain('session-start'); + expect(events).toContain('stop'); + expect(events).toContain('post-tool-use'); + expect(events).toContain('prompt-submit'); + }); + + it('session-start has pull and dashboard-report handlers', () => { + const registry = buildHandlerRegistry(); + const sessionStartHandlers = registry + .filter((r) => r.event === 'session-start' && r.matcher === '*') + .map((r) => r.handler.name); + expect(sessionStartHandlers).toContain('pull'); + expect(sessionStartHandlers).toContain('dashboard-report'); + }); + + it('stop has update, contribute-check, and dashboard-report handlers', () => { + const registry = buildHandlerRegistry(); + const stopHandlers = registry + .filter((r) => r.event === 'stop' && r.matcher === '*') + .map((r) => r.handler.name); + expect(stopHandlers).toContain('update'); + expect(stopHandlers).toContain('contribute-check'); + expect(stopHandlers).toContain('dashboard-report'); + }); + + it('post-tool-use wildcard has dashboard-report', () => { + const registry = buildHandlerRegistry(); + const wildcardHandlers = registry + .filter((r) => r.event === 'post-tool-use' && r.matcher === '*') + .map((r) => r.handler.name); + expect(wildcardHandlers).toContain('dashboard-report'); + }); + + it('post-tool-use Skill matcher has track', () => { + const registry = buildHandlerRegistry(); + const skillHandlers = registry + .filter((r) => r.event === 'post-tool-use' && r.matcher === 'Skill') + .map((r) => r.handler.name); + expect(skillHandlers).toContain('track'); + }); + + it('post-tool-use Bash/Grep/WebSearch/WebFetch have auto-recall', () => { + const registry = buildHandlerRegistry(); + for (const matcher of ['Bash', 'Grep', 'WebSearch', 'WebFetch']) { + const handlers = registry + .filter((r) => r.event === 'post-tool-use' && r.matcher === matcher) + .map((r) => r.handler.name); + expect(handlers).toContain('auto-recall'); + } + }); + + it('prompt-submit has track-slash and dashboard-report', () => { + const registry = buildHandlerRegistry(); + const handlers = registry + .filter((r) => r.event === 'prompt-submit' && r.matcher === '*') + .map((r) => r.handler.name); + expect(handlers).toContain('track-slash'); + expect(handlers).toContain('dashboard-report'); + }); + + it('all handlers have timeoutMs set', () => { + const registry = buildHandlerRegistry(); + for (const reg of registry) { + expect(reg.timeoutMs).toBeGreaterThan(0); + } + }); + + it('pull handler has a longer timeout than dashboard-report', () => { + const registry = buildHandlerRegistry(); + const pull = registry.find((r) => r.handler.name === 'pull'); + const dashboard = registry.find((r) => r.handler.name === 'dashboard-report'); + expect(pull!.timeoutMs).toBeGreaterThan(dashboard!.timeoutMs!); + }); +}); diff --git a/src/__tests__/hooks-dispatch-format.test.ts b/src/__tests__/hooks-dispatch-format.test.ts new file mode 100644 index 0000000..62d6d14 --- /dev/null +++ b/src/__tests__/hooks-dispatch-format.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────── + +let mockFiles: Record = {}; + +vi.mock('../utils/fs.js', () => ({ + readJson: vi.fn(async (filePath: string) => mockFiles[filePath] ?? null), + writeJson: vi.fn(async (filePath: string, data: unknown) => { + mockFiles[filePath] = JSON.parse(JSON.stringify(data)); + }), + expandHome: (p: string) => p, + ensureDir: vi.fn(), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { injectHooks } from '../hooks.js'; + +// ── Helpers ────────────────────────────────────────────── + +interface ClaudeHookEntry { + type: string; + command: string; + timeout?: number; +} + +interface ClaudeHookMatcher { + matcher: string; + hooks: ClaudeHookEntry[]; + description?: string; +} + +interface CursorHookEntry { + command: string; + timeout?: number; + matcher?: string; +} + +// ── Tests ──────────────────────────────────────────────── + +describe('hooks — merged dispatch format', () => { + beforeEach(() => { + mockFiles = {}; + vi.clearAllMocks(); + }); + + describe('Claude format', () => { + it('uses hook-dispatch commands instead of individual subcommands', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const allCommands: string[] = []; + for (const matchers of Object.values(result.hooks)) { + for (const m of matchers) { + for (const h of m.hooks) { + allCommands.push(h.command); + } + } + } + + // All commands should use hook-dispatch + const dispatchCommands = allCommands.filter((c) => c.includes('teamai hook-dispatch')); + expect(dispatchCommands.length).toBe(allCommands.length); + // No individual subcommands like "teamai pull", "teamai track", etc. + const legacyCommands = allCommands.filter( + (c) => c.includes('teamai pull') || c.includes('teamai track') || c.includes('teamai dashboard-report'), + ); + expect(legacyCommands).toHaveLength(0); + }); + + it('produces fewer hook entries than the old format (13 → merged)', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + let totalEntries = 0; + for (const matchers of Object.values(result.hooks)) { + totalEntries += matchers.length; + } + + // Should have: SessionStart(1) + Stop(1) + PostToolUse(Skill:1, *:1, Bash:1, Grep:1, WebSearch:1, WebFetch:1) + UserPromptSubmit(1) = 9 + expect(totalEntries).toBeLessThanOrEqual(9); + }); + + it('SessionStart has exactly one entry with wildcard matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const sessionStart = result.hooks.SessionStart; + expect(sessionStart).toHaveLength(1); + expect(sessionStart[0].matcher).toBe('*'); + expect(sessionStart[0].hooks[0].command).toContain('hook-dispatch session-start'); + }); + + it('Stop has exactly one entry with wildcard matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const stop = result.hooks.Stop; + expect(stop).toHaveLength(1); + expect(stop[0].matcher).toBe('*'); + expect(stop[0].hooks[0].command).toContain('hook-dispatch stop'); + }); + + it('PostToolUse has entries for each distinct matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const postToolUse = result.hooks.PostToolUse; + const matchers = postToolUse.map((m: ClaudeHookMatcher) => m.matcher).sort(); + expect(matchers).toEqual(['*', 'Bash', 'Grep', 'Skill', 'WebFetch', 'WebSearch']); + }); + + it('UserPromptSubmit has exactly one entry with wildcard matcher', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const promptSubmit = result.hooks.UserPromptSubmit; + expect(promptSubmit).toHaveLength(1); + expect(promptSubmit[0].matcher).toBe('*'); + expect(promptSubmit[0].hooks[0].command).toContain('hook-dispatch prompt-submit'); + }); + + it('includes --tool parameter in dispatch commands', async () => { + await injectHooks('/test/settings.json', 'claude-internal'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const cmd = result.hooks.SessionStart[0].hooks[0].command; + expect(cmd).toContain('--tool claude-internal'); + }); + + it('includes --matcher parameter for non-wildcard PostToolUse entries', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const bashEntry = result.hooks.PostToolUse.find((m: ClaudeHookMatcher) => m.matcher === 'Bash'); + expect(bashEntry!.hooks[0].command).toContain('--matcher Bash'); + }); + + it('does not include --matcher for wildcard entries', async () => { + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const wildcardEntry = result.hooks.SessionStart[0]; + expect(wildcardEntry.hooks[0].command).not.toContain('--matcher'); + }); + }); + + describe('Cursor format', () => { + it('uses hook-dispatch commands', async () => { + await injectHooks('/test/hooks.json', 'cursor'); + + const result = mockFiles['/test/hooks.json'] as { hooks: Record }; + const allCommands: string[] = []; + for (const entries of Object.values(result.hooks)) { + for (const entry of entries) { + allCommands.push(entry.command); + } + } + + const dispatchCommands = allCommands.filter((c) => c.includes('teamai hook-dispatch')); + expect(dispatchCommands.length).toBe(allCommands.length); + }); + + it('Cursor hooks have correct camelCase event names in commands', async () => { + await injectHooks('/test/hooks.json', 'cursor'); + + const result = mockFiles['/test/hooks.json'] as { hooks: Record }; + expect(result.hooks.sessionStart).toBeDefined(); + expect(result.hooks.stop).toBeDefined(); + expect(result.hooks.postToolUse).toBeDefined(); + expect(result.hooks.beforeSubmitPrompt).toBeDefined(); + }); + }); + + describe('migration — legacy cleanup', () => { + it('removes old-format hooks and replaces with dispatch format', async () => { + // Pre-populate with legacy format + mockFiles['/test/settings.json'] = { + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'bash -lc "teamai pull 2>/dev/null" || true' }], + description: '[teamai] Auto-pull team resources on session start', + }, + { + matcher: '*', + hooks: [{ type: 'command', command: 'bash -lc "teamai dashboard-report --stdin --tool claude 2>/dev/null" || true' }], + description: '[teamai] Dashboard report on session start', + }, + ], + }, + }; + + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + // Legacy entries should be cleaned up and replaced with single dispatch entry + expect(result.hooks.SessionStart).toHaveLength(1); + expect(result.hooks.SessionStart[0].hooks[0].command).toContain('hook-dispatch'); + }); + + it('preserves non-teamai hooks during migration', async () => { + mockFiles['/test/settings.json'] = { + hooks: { + SessionStart: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'bash -lc "teamai pull 2>/dev/null" || true' }], + description: '[teamai] Auto-pull team resources on session start', + }, + { + matcher: '*', + hooks: [{ type: 'command', command: 'echo "custom hook"' }], + description: 'My custom hook', + }, + ], + }, + }; + + await injectHooks('/test/settings.json', 'claude'); + + const result = mockFiles['/test/settings.json'] as { hooks: Record }; + const customHook = result.hooks.SessionStart.find( + (m: ClaudeHookMatcher) => m.description === 'My custom hook', + ); + expect(customHook).toBeDefined(); + }); + }); +}); diff --git a/src/__tests__/hooks-e2e.test.ts b/src/__tests__/hooks-e2e.test.ts index 098c723..23fdb45 100644 --- a/src/__tests__/hooks-e2e.test.ts +++ b/src/__tests__/hooks-e2e.test.ts @@ -39,7 +39,7 @@ async function readResult(filePath: string): Promise> { describe('hooks E2E — real file I/O', () => { describe('inject — full injection to temp directories', () => { - it('creates Claude settings.json with all 4 events and 13 hooks', async () => { + it('creates Claude settings.json with all 4 events and 9 dispatch hooks', async () => { const p = claudePath(); await injectHooks(p, 'claude'); @@ -47,13 +47,13 @@ describe('hooks E2E — real file I/O', () => { const hooks = result.hooks as Record; expect(Object.keys(hooks)).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']); - expect(hooks.SessionStart).toHaveLength(2); - expect(hooks.Stop).toHaveLength(3); + expect(hooks.SessionStart).toHaveLength(1); + expect(hooks.Stop).toHaveLength(1); expect(hooks.PostToolUse).toHaveLength(6); - expect(hooks.UserPromptSubmit).toHaveLength(2); + expect(hooks.UserPromptSubmit).toHaveLength(1); }); - it('creates Cursor hooks.json with all 4 events and 13 hooks', async () => { + it('creates Cursor hooks.json with all 4 events and 9 dispatch hooks', async () => { const p = cursorPath(); await injectHooks(p, 'cursor'); @@ -62,10 +62,10 @@ describe('hooks E2E — real file I/O', () => { const hooks = result.hooks as Record; expect(Object.keys(hooks)).toEqual(['sessionStart', 'stop', 'postToolUse', 'beforeSubmitPrompt']); - expect(hooks.sessionStart).toHaveLength(2); - expect(hooks.stop).toHaveLength(3); + expect(hooks.sessionStart).toHaveLength(1); + expect(hooks.stop).toHaveLength(1); expect(hooks.postToolUse).toHaveLength(6); - expect(hooks.beforeSubmitPrompt).toHaveLength(2); + expect(hooks.beforeSubmitPrompt).toHaveLength(1); }); it('all TEAMAI_HOOK_SUBCOMMANDS present in Claude output', async () => { @@ -123,7 +123,7 @@ describe('hooks E2E — real file I/O', () => { } }); - it('PostToolUse track hook uses Skill matcher in both formats', async () => { + it('PostToolUse dispatch hook uses Skill matcher in both formats', async () => { const cp = claudePath(); const kp = cursorPath(); await injectHooks(cp, 'claude'); @@ -138,11 +138,9 @@ describe('hooks E2E — real file I/O', () => { const claudeSkillHook = claudePostTool.find((h) => h.matcher === 'Skill'); expect(claudeSkillHook).toBeDefined(); - const cursorTrackHook = cursorPostTool.find( - (h) => h.command.includes('teamai track') && !h.command.includes('track-slash') - ); - expect(cursorTrackHook).toBeDefined(); - expect(cursorTrackHook?.matcher).toBe('Skill'); + const cursorSkillHook = cursorPostTool.find((h) => h.matcher === 'Skill'); + expect(cursorSkillHook).toBeDefined(); + expect(cursorSkillHook?.command).toContain('hook-dispatch'); }); it('tool-specific commands use the correct --tool parameter', async () => { diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 8a69f2b..4f56cb2 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -23,7 +23,7 @@ vi.mock('../utils/logger.js', () => ({ }, })); -import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; +import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; // ── Helpers ────────────────────────────────────────────── @@ -62,7 +62,7 @@ describe('hooks', () => { }); describe('inject — empty file', () => { - it('Claude format: injects 4 events with 13 hooks into empty settings.json', async () => { + it('Claude format: injects 4 events with 9 dispatch hooks into empty settings.json', async () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; @@ -71,16 +71,15 @@ describe('hooks', () => { const events = Object.keys(result.hooks); expect(events).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']); - // Stop has 3 hooks (update, dashboard-stop, contribute-check) - // PostToolUse has 6 hooks (track-skill, dashboard-tool, 4x auto-recall per tool) - // Others have 2 each - expect(result.hooks['SessionStart']).toHaveLength(2); - expect(result.hooks['Stop']).toHaveLength(3); + // Merged format: one dispatch entry per event+matcher + // SessionStart(*:1), Stop(*:1), PostToolUse(*:1, Skill:1, Bash:1, Grep:1, WebSearch:1, WebFetch:1), UserPromptSubmit(*:1) + expect(result.hooks['SessionStart']).toHaveLength(1); + expect(result.hooks['Stop']).toHaveLength(1); expect(result.hooks['PostToolUse']).toHaveLength(6); - expect(result.hooks['UserPromptSubmit']).toHaveLength(2); + expect(result.hooks['UserPromptSubmit']).toHaveLength(1); }); - it('Cursor format: injects 4 events with 13 hooks into empty hooks.json', async () => { + it('Cursor format: injects 4 events with 9 dispatch hooks into empty hooks.json', async () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { version: number; hooks: Record }; @@ -90,12 +89,11 @@ describe('hooks', () => { const events = Object.keys(result.hooks); expect(events).toEqual(['sessionStart', 'stop', 'postToolUse', 'beforeSubmitPrompt']); - // stop has 3 hooks (update, dashboard-stop, contribute-check) - // postToolUse has 6 hooks (track, dashboard, 4x auto-recall per tool) - expect(result.hooks['sessionStart']).toHaveLength(2); - expect(result.hooks['stop']).toHaveLength(3); + // Same merged structure + expect(result.hooks['sessionStart']).toHaveLength(1); + expect(result.hooks['stop']).toHaveLength(1); expect(result.hooks['postToolUse']).toHaveLength(6); - expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); + expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); it('Claude uses PascalCase event names', async () => { @@ -121,10 +119,10 @@ describe('hooks', () => { await injectHooks('/test/settings.json', 'claude'); const result = mockFiles['/test/settings.json'] as { hooks: Record }; - expect(result.hooks['SessionStart']).toHaveLength(2); - expect(result.hooks['Stop']).toHaveLength(3); + expect(result.hooks['SessionStart']).toHaveLength(1); + expect(result.hooks['Stop']).toHaveLength(1); expect(result.hooks['PostToolUse']).toHaveLength(6); - expect(result.hooks['UserPromptSubmit']).toHaveLength(2); + expect(result.hooks['UserPromptSubmit']).toHaveLength(1); }); it('double inject for Cursor does not duplicate hooks', async () => { @@ -132,13 +130,14 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record }; - expect(result.hooks['sessionStart']).toHaveLength(2); - expect(result.hooks['stop']).toHaveLength(3); + expect(result.hooks['sessionStart']).toHaveLength(1); + expect(result.hooks['stop']).toHaveLength(1); expect(result.hooks['postToolUse']).toHaveLength(6); - expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); + expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); it('updates command when content changes (Claude)', async () => { + // Simulate legacy hook that will be cleaned up and replaced with dispatch mockFiles['/test/settings.json'] = { hooks: { SessionStart: [ @@ -155,11 +154,12 @@ describe('hooks', () => { const result = mockFiles['/test/settings.json'] as { hooks: Record }; const sessionStart = result.hooks.SessionStart as Array<{ hooks: Array<{ command: string }> }>; - expect(sessionStart[0].hooks[0].command).toContain('teamai pull'); - expect(sessionStart[0].hooks[0].command).not.toContain('--silent'); + // Legacy format cleaned up, replaced with hook-dispatch + expect(sessionStart[0].hooks[0].command).toContain('hook-dispatch'); }); it('updates command when content changes (Cursor)', async () => { + // Simulate legacy hook mockFiles['/test/hooks.json'] = { version: 1, hooks: { @@ -172,8 +172,8 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record> }; - const pullHook = result.hooks.sessionStart.find((h) => h.command.includes('teamai pull')); - expect(pullHook?.command).not.toContain('--silent'); + // Legacy format cleaned up, replaced with hook-dispatch + expect(result.hooks.sessionStart[0].command).toContain('hook-dispatch'); }); }); @@ -195,7 +195,8 @@ describe('hooks', () => { hooks: Record; language: string; }; - expect(result.hooks.SessionStart).toHaveLength(3); + // User hook + 1 dispatch entry + expect(result.hooks.SessionStart).toHaveLength(2); expect(result.hooks.SessionStart[0]).toEqual(userHook); expect(result.language).toBe('en'); }); @@ -210,7 +211,8 @@ describe('hooks', () => { await injectHooks('/test/hooks.json', 'cursor'); const result = mockFiles['/test/hooks.json'] as { hooks: Record }; - expect(result.hooks.sessionStart).toHaveLength(3); + // User hook + 1 dispatch entry + expect(result.hooks.sessionStart).toHaveLength(2); expect(result.hooks.sessionStart[0]).toEqual(userHook); }); }); @@ -273,7 +275,8 @@ describe('hooks', () => { const result = mockFiles['/test/hooks.json'] as { hooks: Record }; expect(result.hooks['userPromptSubmit']).toBeUndefined(); - expect(result.hooks['beforeSubmitPrompt']).toHaveLength(2); + // New merged format: single dispatch entry + expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1); }); it('Cursor inject preserves user hooks in stale event keys', async () => { @@ -399,20 +402,21 @@ describe('hooks', () => { } }); - it('PostToolUse/postToolUse track hook uses Skill matcher in both formats', async () => { + it('PostToolUse/postToolUse Skill matcher dispatch hook exists in both formats', async () => { await injectHooks('/test/claude.json', 'claude'); await injectHooks('/test/cursor.json', 'cursor'); const claudeResult = mockFiles['/test/claude.json'] as { hooks: Record> }; const cursorResult = mockFiles['/test/cursor.json'] as { hooks: Record> }; - const claudeTrack = claudeResult.hooks.PostToolUse.find((h) => h.matcher === 'Skill'); - expect(claudeTrack).toBeDefined(); + const claudeSkill = claudeResult.hooks.PostToolUse.find((h) => h.matcher === 'Skill'); + expect(claudeSkill).toBeDefined(); - const cursorTrack = cursorResult.hooks.postToolUse.find( - (h) => h.command.includes('teamai track') && !h.command.includes('track-slash') + const cursorSkill = cursorResult.hooks.postToolUse.find( + (h) => h.matcher === 'Skill' ); - expect(cursorTrack?.matcher).toBe('Skill'); + expect(cursorSkill).toBeDefined(); + expect(cursorSkill!.command).toContain('hook-dispatch'); }); it('Cursor hooks have timeout values', async () => { @@ -457,12 +461,19 @@ describe('hooks', () => { }); describe('TEAMAI_HOOK_SUBCOMMANDS export', () => { - it('contains all expected subcommands', () => { - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('pull'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('update'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('track-slash'); - expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('dashboard-report'); + it('contains hook-dispatch as the unified subcommand', () => { + expect(TEAMAI_HOOK_SUBCOMMANDS).toContain('hook-dispatch'); + expect(TEAMAI_HOOK_SUBCOMMANDS).toHaveLength(1); + }); + + it('TEAMAI_LEGACY_HOOK_SUBCOMMANDS contains all old subcommands for cleanup', () => { + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('pull'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('update'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('track'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('track-slash'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('dashboard-report'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('contribute-check'); + expect(TEAMAI_LEGACY_HOOK_SUBCOMMANDS).toContain('auto-recall'); }); }); diff --git a/src/__tests__/usage-tracking.test.ts b/src/__tests__/usage-tracking.test.ts index 3a5e66d..2adf7a8 100644 --- a/src/__tests__/usage-tracking.test.ts +++ b/src/__tests__/usage-tracking.test.ts @@ -1135,7 +1135,7 @@ describe('track with tool parameter', () => { // ─── hook command string tests ──────────────────────── describe('hook command strings', () => { - it('generates track commands with --tool parameter', async () => { + it('generates dispatch commands with --tool parameter', async () => { // Import the module to test hook injection const { injectHooks } = await import('../hooks.js'); const settingsPath = path.join(tmpDir, '.test-claude', 'settings.json'); @@ -1145,26 +1145,26 @@ describe('hook command strings', () => { const settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // Check PostToolUse hook has --tool claude-internal + // Check PostToolUse hooks have --tool claude-internal const postToolUse = settings.hooks?.PostToolUse; expect(postToolUse).toBeDefined(); - const trackHook = postToolUse.find((h: { description?: string }) => - h.description?.includes('Track skill'), + const skillHook = postToolUse.find((h: { description?: string }) => + h.description?.includes('Hook dispatch post-tool-use Skill'), ); - expect(trackHook).toBeDefined(); - expect(trackHook.hooks[0].command).toContain('--tool claude-internal'); + expect(skillHook).toBeDefined(); + expect(skillHook.hooks[0].command).toContain('--tool claude-internal'); // Check UserPromptSubmit hook has --tool claude-internal const userPrompt = settings.hooks?.UserPromptSubmit; expect(userPrompt).toBeDefined(); - const slashHook = userPrompt.find((h: { description?: string }) => - h.description?.includes('Track slash'), + const promptHook = userPrompt.find((h: { description?: string }) => + h.description?.includes('Hook dispatch prompt-submit'), ); - expect(slashHook).toBeDefined(); - expect(slashHook.hooks[0].command).toContain('--tool claude-internal'); + expect(promptHook).toBeDefined(); + expect(promptHook.hooks[0].command).toContain('--tool claude-internal'); }); - it('generates track commands with --tool claude for default tool', async () => { + it('generates dispatch commands with --tool claude for default tool', async () => { const { injectHooks } = await import('../hooks.js'); const settingsPath = path.join(tmpDir, '.test-claude2', 'settings.json'); await fse.ensureDir(path.dirname(settingsPath)); @@ -1173,10 +1173,10 @@ describe('hook command strings', () => { const settings = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); const postToolUse = settings.hooks?.PostToolUse; - const trackHook = postToolUse.find((h: { description?: string }) => - h.description?.includes('Track skill'), + const skillHook = postToolUse.find((h: { description?: string }) => + h.description?.includes('Hook dispatch post-tool-use Skill'), ); - expect(trackHook.hooks[0].command).toContain('--tool claude'); + expect(skillHook.hooks[0].command).toContain('--tool claude'); }); it('cleans up legacy hooks without description on inject', async () => { @@ -1207,22 +1207,16 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // Legacy duplicates should be cleaned, replaced by proper hooks with description - // SessionStart has 2 hooks: Auto-pull + Dashboard report - expect(result.hooks.SessionStart).toHaveLength(2); - expect(result.hooks.SessionStart.every((h: { description?: string }) => h.description)).toBe(true); + // Legacy duplicates should be cleaned, replaced by single dispatch entry with description + // SessionStart has 1 hook (hook-dispatch session-start) + expect(result.hooks.SessionStart).toHaveLength(1); + expect(result.hooks.SessionStart[0].description).toContain('[teamai]'); + expect(result.hooks.SessionStart[0].hooks[0].command).toContain('hook-dispatch'); - // Stop has 3 hooks: Auto-update + Dashboard stop + Contribute check - expect(result.hooks.Stop).toHaveLength(3); - expect(result.hooks.Stop.every((h: { description?: string }) => h.description)).toBe(true); - - // Auto-update hook must have a 10s timeout — npm registry call should not - // delay session shutdown by Claude Code's default 60s if it stalls. - const updateHook = result.hooks.Stop.find((h: { description?: string }) => - h.description?.includes('Auto-update'), - ); - expect(updateHook).toBeDefined(); - expect(updateHook.hooks[0].timeout).toBe(10); + // Stop has 1 hook (hook-dispatch stop) + expect(result.hooks.Stop).toHaveLength(1); + expect(result.hooks.Stop[0].description).toContain('[teamai]'); + expect(result.hooks.Stop[0].hooks[0].command).toContain('hook-dispatch'); // Non-teamai hooks should be preserved expect(result.hooks.PreToolUse).toHaveLength(1); @@ -1248,18 +1242,18 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // continuous-learning hook preserved, legacy teamai track removed + replaced with proper one + // continuous-learning hook preserved, legacy teamai track removed + replaced with dispatch const observeHooks = result.hooks.PostToolUse.filter( (h: { hooks?: Array<{ command: string }> }) => h.hooks?.[0]?.command?.includes('observe.sh'), ); expect(observeHooks).toHaveLength(1); - // teamai track hook should have description now - const trackHooks = result.hooks.PostToolUse.filter( - (h: { description?: string }) => h.description?.includes('Track skill'), + // teamai dispatch hook should exist with Skill matcher + const skillHooks = result.hooks.PostToolUse.filter( + (h: { description?: string }) => h.description?.includes('Hook dispatch post-tool-use Skill'), ); - expect(trackHooks).toHaveLength(1); - expect(trackHooks[0].hooks[0].command).toContain('--tool claude'); + expect(skillHooks).toHaveLength(1); + expect(skillHooks[0].hooks[0].command).toContain('--tool claude'); }); it('cleans up hooks with outdated description keywords', async () => { @@ -1267,8 +1261,7 @@ describe('hook command strings', () => { const settingsPath = path.join(tmpDir, '.test-outdated-desc', 'settings.json'); await fse.ensureDir(path.dirname(settingsPath)); - // Simulate: old description "Check for updates" + current "Auto-update" both present - // CodeBuddy uses PascalCase keys (same as Claude) — HookExecutor looks up by PascalCase + // Simulate: old description hooks that should be cleaned up const outdatedSettings = { hooks: { Stop: [ @@ -1291,14 +1284,10 @@ describe('hook command strings', () => { const result = JSON.parse(await fs.promises.readFile(settingsPath, 'utf-8')); - // Legacy entries cleaned up, fresh hooks injected under PascalCase "Stop" key - // (update + dashboard-report + contribute-check = 3) - expect(result.hooks.Stop).toHaveLength(3); - const updateHook = result.hooks.Stop.find((h: { description?: string }) => - h.description?.includes('Auto-update'), - ); - expect(updateHook).toBeDefined(); - expect(updateHook.hooks[0].command).toContain('teamai update'); + // Legacy entries cleaned up, replaced with single dispatch entry + expect(result.hooks.Stop).toHaveLength(1); + expect(result.hooks.Stop[0].hooks[0].command).toContain('hook-dispatch stop'); + expect(result.hooks.Stop[0].hooks[0].command).toContain('--tool codebuddy'); }); }); diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts new file mode 100644 index 0000000..5eb5742 --- /dev/null +++ b/src/hook-dispatch-cli.ts @@ -0,0 +1,54 @@ +/** + * CLI entry point for `teamai hook-dispatch --tool [--matcher ]`. + * + * Reads STDIN once, creates the dispatcher with the full handler registry, + * and dispatches to all matching handlers. Outputs any handler result to STDOUT. + */ + +import { createDispatcher } from './hook-dispatch.js'; +import { buildHandlerRegistry } from './hook-handlers.js'; +import { log } from './utils/logger.js'; + +/** Read STDIN fully. Returns empty string if STDIN is a TTY. */ +async function readStdin(): Promise { + if (process.stdin.isTTY) return ''; + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +/** + * Main CLI handler for hook-dispatch. + */ +export async function hookDispatchCli(event: string, tool: string, matcher: string): Promise { + // Read STDIN once — shared across all handlers + const raw = await readStdin(); + let stdin: Record = {}; + if (raw.trim()) { + try { + stdin = JSON.parse(raw); + } catch { + log.debug(`hook-dispatch: failed to parse STDIN JSON for event=${event}`); + return; + } + } + + // Build dispatcher with full handler registry + const registry = buildHandlerRegistry(); + const dispatcher = createDispatcher({ handlers: registry }); + + // Dispatch + const result = await dispatcher.dispatch(event, matcher, stdin, tool); + + // Log errors (to debug, not STDOUT — STDOUT is reserved for hook output) + for (const err of result.errors) { + log.debug(`hook-dispatch: handler "${err.handlerName}" failed: ${err.error.message}`); + } + + // Write output to STDOUT if any handler produced one + if (result.output) { + process.stdout.write(result.output); + } +} diff --git a/src/hook-dispatch.ts b/src/hook-dispatch.ts new file mode 100644 index 0000000..132e020 --- /dev/null +++ b/src/hook-dispatch.ts @@ -0,0 +1,118 @@ +/** + * Hook Dispatcher — unified entry point for teamai hooks. + * + * Instead of spawning N separate processes per event, Claude Code invokes + * a single `teamai hook-dispatch [--matcher ]` command. + * The dispatcher reads STDIN once and fans out to all registered handlers. + * + * Design: + * - Handlers are pure functions: (stdin, tool) → output | null + * - Promise.allSettled ensures one handler crash doesn't take down others + * - At most one handler per event produces STDOUT output + */ + +// ─── Public types ─────────────────────────────────────── + +export interface HookHandler { + name: string; + execute(stdin: Record, tool: string): Promise; +} + +export interface HandlerRegistration { + event: string; + matcher: string; + handler: HookHandler; + /** Per-handler timeout in ms. If exceeded, handler is treated as failed. */ + timeoutMs?: number; +} + +export interface DispatchError { + handlerName: string; + error: Error; +} + +export interface DispatchResult { + /** Combined STDOUT output (at most one handler produces output per event). */ + output: string | null; + /** Errors from failed handlers (non-fatal — other handlers still ran). */ + errors: DispatchError[]; +} + +export interface DispatcherConfig { + handlers: HandlerRegistration[]; +} + +export interface Dispatcher { + dispatch(event: string, matcher: string, stdin: Record, tool: string): Promise; +} + +// ─── Implementation ───────────────────────────────────── + +/** Default timeout: 60 seconds (matches Claude Code's default hook timeout). */ +const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * Wrap a promise with a timeout. Rejects with a timeout error if not resolved in time. + */ +function withTimeout(promise: Promise, ms: number, handlerName: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Handler "${handlerName}" exceeded timeout of ${ms}ms`)); + }, ms); + promise.then( + (val) => { clearTimeout(timer); resolve(val); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); +} + +/** + * Create a dispatcher with the given handler registrations. + * + * Routing rules: + * - A handler matches if its event matches AND (its matcher === dispatched matcher OR its matcher === '*') + * - Wildcard ('*') matchers fire for any dispatched matcher on that event + */ +export function createDispatcher(config: DispatcherConfig): Dispatcher { + return { + async dispatch(event, matcher, stdin, tool): Promise { + // Find all handlers that should fire for this event+matcher + const matched = config.handlers.filter((reg) => { + if (reg.event !== event) return false; + // Wildcard handlers always fire; specific matchers must match exactly + return reg.matcher === '*' || reg.matcher === matcher; + }); + + // Execute all matched handlers concurrently with isolation + per-handler timeout + const settled = await Promise.allSettled( + matched.map((reg) => { + const timeoutMs = reg.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return withTimeout(reg.handler.execute(stdin, tool), timeoutMs, reg.handler.name); + }), + ); + + // Collect results + let output: string | null = null; + const errors: DispatchError[] = []; + + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + const handlerName = matched[i].handler.name; + + if (result.status === 'rejected') { + errors.push({ + handlerName, + error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)), + }); + } else if (result.status === 'fulfilled' && result.value != null) { + // First non-null output wins (at most one handler should produce output per event) + if (output === null) { + output = result.value; + } + } + } + + return { output, errors }; + }, + }; +} diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts new file mode 100644 index 0000000..9c6aa4d --- /dev/null +++ b/src/hook-handlers.ts @@ -0,0 +1,226 @@ +/** + * Hook Handler Registry — maps event+matcher to concrete handler implementations. + * + * Each handler wraps an existing teamai subcommand function but accepts pre-parsed + * STDIN data instead of reading from process.stdin directly. This enables the + * dispatcher to read STDIN once and fan out to all handlers. + * + * Existing standalone subcommands (`teamai pull`, `teamai track --stdin`, etc.) + * remain unchanged for backward compatibility during migration. + */ + +import type { HookHandler } from './hook-dispatch.js'; + +// ─── Public types ─────────────────────────────────────── + +export interface HandlerRegistration { + event: string; + matcher: string; + handler: HookHandler; + timeoutMs: number; +} + +// ─── Timeout constants ────────────────────────────────── + +/** Pull involves git network ops — generous timeout. */ +const PULL_TIMEOUT_MS = 60_000; +/** Update checks npm registry — cap at 10s to avoid blocking session shutdown. */ +const UPDATE_TIMEOUT_MS = 10_000; +/** Track/track-slash is a local file append — very fast. */ +const TRACK_TIMEOUT_MS = 5_000; +/** Dashboard-report is a local file append — very fast. */ +const DASHBOARD_TIMEOUT_MS = 5_000; +/** Contribute-check reads local state + events.jsonl — generally fast. */ +const CONTRIBUTE_CHECK_TIMEOUT_MS = 10_000; +/** Auto-recall involves search index lookup — usually <200ms. */ +const AUTO_RECALL_TIMEOUT_MS = 10_000; + +// ─── Handler implementations ──────────────────────────── +// +// Each handler is a thin adapter that: +// 1. Receives pre-parsed STDIN (Record) +// 2. Delegates to the actual subcommand logic +// 3. Returns output string or null +// +// IMPORTANT: These use dynamic imports to keep module loading lazy. +// The dispatcher only loads the modules that actually need to run. + +const pullHandler: HookHandler = { + name: 'pull', + async execute(_stdin, _tool) { + const { pull } = await import('./pull.js'); + await pull({ silent: true }); + return null; + }, +}; + +const updateHandler: HookHandler = { + name: 'update', + async execute(_stdin, _tool) { + const { doUpdate } = await import('./update.js'); + await doUpdate(); + return null; + }, +}; + +const dashboardReportHandler: HookHandler = { + name: 'dashboard-report', + async execute(stdin, tool) { + const { parseHookEvent, appendEvent, compactEvents } = await import('./dashboard-collector.js'); + const raw = JSON.stringify(stdin); + const event = await parseHookEvent(raw, tool); + if (event) { + await appendEvent(event); + // Non-blocking compaction + compactEvents().catch(() => {}); + } + return null; + }, +}; + +const trackHandler: HookHandler = { + name: 'track', + async execute(stdin, tool) { + const { extractSkillName, isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + + const toolName = stdin.tool_name; + if (typeof toolName !== 'string') return null; + + const toolInput = stdin.tool_input; + if (!toolInput || typeof toolInput !== 'object') return null; + + // Only track Skill (Claude) or Read+SKILL.md (Cursor) + let skillName: string | null = null; + let toolSource = tool; + + if (toolName === 'Skill') { + skillName = extractSkillName(toolInput as Record); + } else if (toolName === 'Read') { + const filePath = + (typeof (toolInput as Record).file_path === 'string' + ? (toolInput as Record).file_path + : null) ?? + (typeof (toolInput as Record).path === 'string' + ? (toolInput as Record).path + : null); + if (typeof filePath === 'string' && /\/SKILL\.md$/i.test(filePath)) { + skillName = extractSkillName({ skill: filePath }); + toolSource = 'cursor'; + } + } else { + return null; + } + + if (!skillName || !isValidSkillName(skillName)) return null; + + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool: toolSource }); + await updateKnownSkills(skillName); + return null; + }, +}; + +const trackSlashHandler: HookHandler = { + name: 'track-slash', + async execute(stdin, tool) { + const { extractSkillName, isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); + + const prompt = stdin.prompt; + if (typeof prompt !== 'string' || !prompt.startsWith('/')) return null; + + // Extract skill name: first word after "/" + const match = prompt.match(/^\/([\w-]+)/); + if (!match) return null; + + const skillName = match[1]; + if (!isValidSkillName(skillName)) return null; + + await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }); + await updateKnownSkills(skillName); + return null; + }, +}; + +const contributeCheckHandler: HookHandler = { + name: 'contribute-check', + async execute(stdin, _tool) { + const { contributeCheckForSession } = await import('./contribute-check.js'); + + // Derive session ID from STDIN + const sessionId = typeof stdin.session_id === 'string' ? stdin.session_id : null; + if (!sessionId) return null; + + const { hint } = await contributeCheckForSession(sessionId); + if (hint) { + // Stop event format: { stopReason: "..." } + return JSON.stringify({ stopReason: hint }); + } + return null; + }, +}; + +const autoRecallHandler: HookHandler = { + name: 'auto-recall', + async execute(stdin, _tool) { + // Auto-recall has complex internal logic (tool dispatch, error detection, rate limiting) + // For now, delegate to the existing function by temporarily mocking STDIN. + // TODO: Refactor autoRecall to accept parsed data directly. + const { autoRecall } = await import('./auto-recall.js'); + + // The auto-recall function reads STDIN internally. To avoid changing its signature + // in this phase, we capture its STDOUT output via a process.stdout.write intercept. + let capturedOutput: string | null = null; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + if (typeof chunk === 'string') { + capturedOutput = chunk; + } else if (Buffer.isBuffer(chunk)) { + capturedOutput = chunk.toString(); + } + return true; + }) as typeof process.stdout.write; + + try { + // We can't easily pipe stdin to the function, so for this handler + // we'll rely on the environment (process.stdin being piped from Claude Code). + // In the dispatcher, auto-recall will be invoked with the raw data. + await autoRecall(); + } finally { + process.stdout.write = originalWrite; + } + + return capturedOutput; + }, +}; + +// ─── Registry builder ─────────────────────────────────── + +/** + * Build the complete handler registry for the hook dispatcher. + * Returns all handler registrations with their event, matcher, timeout, and implementation. + */ +export function buildHandlerRegistry(): HandlerRegistration[] { + return [ + // ─── SessionStart ───────────────────────────────── + { event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS }, + { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + + // ─── Stop ───────────────────────────────────────── + { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS }, + { event: 'stop', matcher: '*', handler: contributeCheckHandler, timeoutMs: CONTRIBUTE_CHECK_TIMEOUT_MS }, + { event: 'stop', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + + // ─── PostToolUse ────────────────────────────────── + { event: 'post-tool-use', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + { event: 'post-tool-use', matcher: 'Skill', handler: trackHandler, timeoutMs: TRACK_TIMEOUT_MS }, + ...(['Bash', 'Grep', 'WebSearch', 'WebFetch'] as const).map((m) => ({ + event: 'post-tool-use' as const, + matcher: m, + handler: autoRecallHandler, + timeoutMs: AUTO_RECALL_TIMEOUT_MS, + })), + + // ─── UserPromptSubmit ───────────────────────────── + { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: TRACK_TIMEOUT_MS }, + { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: DASHBOARD_TIMEOUT_MS }, + ]; +} diff --git a/src/hooks.ts b/src/hooks.ts index 2b0d327..b581858 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -3,36 +3,17 @@ import { readJson, writeJson, expandHome, ensureDir } from './utils/fs.js'; import { log } from './utils/logger.js'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; -const TEAMAI_PULL_COMMAND = 'bash -lc "teamai pull 2>/dev/null" || true'; -const TEAMAI_UPDATE_COMMAND = 'bash -lc "teamai update 2>/dev/null" || true'; - -/** Generate the track command with tool identifier for correct usage attribution. */ -function getTrackCommand(tool: string): string { - return `bash -lc "teamai track --stdin --tool ${tool} 2>/dev/null" || true`; -} - -/** Generate the track-slash command with tool identifier. */ -function getTrackSlashCommand(tool: string): string { - return `bash -lc "teamai track-slash --stdin --tool ${tool} 2>/dev/null" || true`; -} - -/** Generate the dashboard-report command with tool identifier. */ -function getDashboardReportCommand(tool: string): string { - return `bash -lc "teamai dashboard-report --stdin --tool ${tool} 2>/dev/null" || true`; -} - -/** Generate the auto-recall command with tool identifier. */ -function getAutoRecallCommand(tool: string): string { - return `bash -lc "teamai auto-recall --stdin 2>/dev/null" || true`; -} - -/** Generate the contribute-check command with tool identifier. */ -function getContributeCheckCommand(tool: string): string { - return `bash -lc "teamai contribute-check --stdin --tool ${tool} 2>/dev/null" || true`; +/** Generate the hook-dispatch command for a given event, tool, and optional matcher. */ +function getDispatchCommand(event: string, tool: string, matcher?: string): string { + const matcherArg = matcher && matcher !== '*' ? ` --matcher ${matcher}` : ''; + return `bash -lc "teamai hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null" || true`; } /** Subcommands expected in each tool settings file (for `teamai doctor`). */ -export const TEAMAI_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report', 'contribute-check', 'auto-recall'] as const; +export const TEAMAI_HOOK_SUBCOMMANDS = ['hook-dispatch'] as const; + +/** Legacy subcommands that are cleaned up during migration. */ +export const TEAMAI_LEGACY_HOOK_SUBCOMMANDS = ['pull', 'update', 'track', 'track-slash', 'dashboard-report', 'contribute-check', 'auto-recall'] as const; /** Claude PascalCase event → Cursor camelCase event (for tests / docs). */ export const CLAUDE_TO_CURSOR_EVENTS: Record = { @@ -93,101 +74,64 @@ interface ClaudeHookDef { /** Build Claude hook definitions with the correct --tool identifier. */ function getClaudeHooks(tool: string): ClaudeHookDef[] { return [ + // ─── SessionStart: single dispatcher handles pull + dashboard-report ──── { eventType: 'SessionStart', - descriptionKeyword: 'Auto-pull', + descriptionKeyword: 'Hook dispatch session-start', hook: { matcher: '*', - hooks: [{ type: 'command', command: TEAMAI_PULL_COMMAND }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-pull team resources on session start`, + hooks: [{ type: 'command', command: getDispatchCommand('session-start', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch session-start`, }, }, + // ─── Stop: single dispatcher handles update + contribute-check + dashboard-report ──── { eventType: 'Stop', - descriptionKeyword: 'Auto-update', + descriptionKeyword: 'Hook dispatch stop', hook: { matcher: '*', - // 10s timeout: npm registry call typically <5s; cap at 10s so a stalled - // call cannot delay session shutdown by the default 60s. - hooks: [{ type: 'command', command: TEAMAI_UPDATE_COMMAND, timeout: 10 }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-update on session end`, + hooks: [{ type: 'command', command: getDispatchCommand('stop', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch stop`, }, }, - // ─── Contribute check (smart threshold hint at session end) ──────── + // ─── PostToolUse (*): dashboard-report ──── { - eventType: 'Stop', - descriptionKeyword: 'Contribute check', + eventType: 'PostToolUse', + descriptionKeyword: 'Hook dispatch post-tool-use wildcard', hook: { matcher: '*', - hooks: [{ type: 'command', command: getContributeCheckCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Contribute check on session end`, + hooks: [{ type: 'command', command: getDispatchCommand('post-tool-use', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch post-tool-use wildcard`, }, }, + // ─── PostToolUse (Skill): track ──── { eventType: 'PostToolUse', - descriptionKeyword: 'Track skill', + descriptionKeyword: 'Hook dispatch post-tool-use Skill', hook: { matcher: 'Skill', - hooks: [{ type: 'command', command: getTrackCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Track skill usage`, + hooks: [{ type: 'command', command: getDispatchCommand('post-tool-use', tool, 'Skill') }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch post-tool-use Skill`, }, }, - { - eventType: 'UserPromptSubmit', - descriptionKeyword: 'Track slash', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getTrackSlashCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Track slash command usage`, - }, - }, - // ─── Auto-recall (search knowledge base on search tools + Bash errors) ──────── - // Split into 4 precise matchers to avoid spawning a process for tools that - // would immediately exit (auto-recall only handles Bash/Grep/WebSearch/WebFetch). + // ─── PostToolUse (Bash/Grep/WebSearch/WebFetch): auto-recall ──── ...(['Bash', 'Grep', 'WebSearch', 'WebFetch'] as const).map((matcher) => ({ eventType: 'PostToolUse' as const, - descriptionKeyword: `Auto-recall ${matcher}`, + descriptionKeyword: `Hook dispatch post-tool-use ${matcher}`, hook: { matcher, - hooks: [{ type: 'command', command: getAutoRecallCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Auto-recall on ${matcher}`, + hooks: [{ type: 'command', command: getDispatchCommand('post-tool-use', tool, matcher) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch post-tool-use ${matcher}`, }, })), - // ─── Dashboard hooks (independent from tracking) ──────── - { - eventType: 'SessionStart', - descriptionKeyword: 'Dashboard report', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on session start`, - }, - }, - { - eventType: 'Stop', - descriptionKeyword: 'Dashboard stop', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on session stop`, - }, - }, - { - eventType: 'PostToolUse', - descriptionKeyword: 'Dashboard tool', - hook: { - matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on tool use`, - }, - }, + // ─── UserPromptSubmit: track-slash + dashboard-report ──── { eventType: 'UserPromptSubmit', - descriptionKeyword: 'Dashboard prompt', + descriptionKeyword: 'Hook dispatch prompt-submit', hook: { matcher: '*', - hooks: [{ type: 'command', command: getDashboardReportCommand(tool) }], - description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Dashboard report on prompt submit`, + hooks: [{ type: 'command', command: getDispatchCommand('prompt-submit', tool) }], + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} Hook dispatch prompt-submit`, }, }, ]; @@ -210,26 +154,22 @@ interface CursorHooksJson { function buildCursorHooks(tool: string): Record { return { sessionStart: [ - { command: TEAMAI_PULL_COMMAND, timeout: 30 }, - { command: getDashboardReportCommand(tool), timeout: 10 }, + { command: getDispatchCommand('session-start', tool), timeout: 60 }, ], stop: [ - { command: TEAMAI_UPDATE_COMMAND, timeout: 10 }, - { command: getDashboardReportCommand(tool), timeout: 10 }, - { command: getContributeCheckCommand(tool), timeout: 10 }, + { command: getDispatchCommand('stop', tool), timeout: 15 }, ], postToolUse: [ - { command: getTrackCommand(tool), timeout: 10, matcher: 'Skill' }, - { command: getDashboardReportCommand(tool), timeout: 10 }, + { command: getDispatchCommand('post-tool-use', tool), timeout: 10 }, + { command: getDispatchCommand('post-tool-use', tool, 'Skill'), timeout: 10, matcher: 'Skill' }, ...(['Bash', 'Grep', 'WebSearch', 'WebFetch'] as const).map((matcher) => ({ - command: getAutoRecallCommand(tool), - timeout: 3, + command: getDispatchCommand('post-tool-use', tool, matcher), + timeout: 10, matcher, })), ], beforeSubmitPrompt: [ - { command: getTrackSlashCommand(tool), timeout: 10 }, - { command: getDashboardReportCommand(tool), timeout: 10 }, + { command: getDispatchCommand('prompt-submit', tool), timeout: 10 }, ], }; } @@ -424,6 +364,26 @@ async function injectCursorHooks(hooksPath: string, tool: string): Promise const desiredHooks = buildCursorHooks(tool); let changed = false; + // Clean up legacy individual teamai hooks (pull, track, dashboard-report, etc.) + // that are being replaced by unified hook-dispatch entries. + for (const event of Object.keys(hooksJson.hooks)) { + const entries = hooksJson.hooks[event]; + const filtered = entries.filter((h) => { + if (!isTeamaiHookCommand(h.command)) return true; + // Keep hook-dispatch entries, remove all legacy individual subcommand entries + const subcmd = extractTeamaiSubcommand(h.command); + return subcmd === 'hook-dispatch'; + }); + if (filtered.length !== entries.length) { + changed = true; + if (filtered.length === 0) { + delete hooksJson.hooks[event]; + } else { + hooksJson.hooks[event] = filtered; + } + } + } + // Clean up stale event keys no longer in the desired set (e.g. userPromptSubmit → beforeSubmitPrompt rename) const desiredEvents = new Set(Object.keys(desiredHooks)); for (const event of Object.keys(hooksJson.hooks)) { diff --git a/src/index.ts b/src/index.ts index fff2e0d..8dff420 100644 --- a/src/index.ts +++ b/src/index.ts @@ -554,4 +554,16 @@ program } }); +// ─── Unified hook dispatch (replaces individual hook subcommands) ──── + +program + .command('hook-dispatch ') + .description('Unified hook dispatcher — handles all teamai hooks for a given event in one process') + .option('--tool ', 'Tool identifier (e.g. claude, claude-internal, cursor)') + .option('--matcher ', 'Hook matcher for PostToolUse (e.g. Skill, Bash)') + .action(async (event: string, cmdOpts: { tool?: string; matcher?: string }) => { + const { hookDispatchCli } = await import('./hook-dispatch-cli.js'); + await hookDispatchCli(event, cmdOpts.tool ?? 'claude', cmdOpts.matcher ?? '*'); + }); + program.parse();