import { describe, expect, it } from 'vitest'; import { HIVE_MIND_MARKER, HOOK_EVENT_BY_BASENAME, defaultHookEntries, hasHiveHooks, mergeHiveHooks, type ClaudeCodeSettings, type HookEntrySpec, } from '../src/settings-merger.js'; import { hookCommandFor } from '../src/paths.js'; const HOOKS_DIR = '/abs/dist/hooks'; function makeEntry(basename: 'session-start' | 'user-prompt-submit' | 'stop' | 'pre-compact'): HookEntrySpec { return { basename, command: hookCommandFor(HOOKS_DIR, basename), timeout: 5, }; } describe('mergeHiveHooks', () => { it('returns a new object — does not mutate input', () => { const original: ClaudeCodeSettings = { hooks: { SessionStart: [] } }; const merged = mergeHiveHooks(original, [makeEntry('session-start')]); expect(merged).not.toBe(original); expect(original.hooks?.SessionStart).toEqual([]); }); it('appends a hive group to each requested event array', () => { const merged = mergeHiveHooks({}, [ makeEntry('session-start'), makeEntry('user-prompt-submit'), makeEntry('stop'), makeEntry('pre-compact'), ]); expect(merged.hooks?.SessionStart).toHaveLength(1); expect(merged.hooks?.UserPromptSubmit).toHaveLength(1); expect(merged.hooks?.Stop).toHaveLength(1); expect(merged.hooks?.PreCompact).toHaveLength(1); }); it('preserves existing hook entries verbatim', () => { const existing: ClaudeCodeSettings = { hooks: { SessionStart: [ { hooks: [{ type: 'command', command: 'node /existing/gsd-context.js', timeout: 10 }] }, ], }, }; const merged = mergeHiveHooks(existing, [makeEntry('session-start')]); const arr = merged.hooks?.SessionStart; expect(arr).toHaveLength(2); expect(arr?.[0].hooks[0].command).toBe('node /existing/gsd-context.js'); expect(arr?.[1]._hiveMindShim).toBe(HIVE_MIND_MARKER); }); it('replaces an existing hive entry when the same command is re-installed (idempotent)', () => { const cmd = hookCommandFor(HOOKS_DIR, 'session-start'); const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: cmd, timeout: 5 }]); const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: cmd, timeout: 7 }]); expect(merged2.hooks?.SessionStart).toHaveLength(1); expect(merged2.hooks?.SessionStart?.[0].hooks[0].timeout).toBe(7); }); it('replaces stale marked entries when the install path changes', () => { const oldCommand = hookCommandFor('/old/dist/hooks', 'session-start', '/old/cli.js'); const newCommand = hookCommandFor('/new/dist/hooks', 'session-start', '/new/cli.js'); const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]); const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: newCommand, timeout: 7 }]); const markedGroups = merged2.hooks?.SessionStart?.filter( (group) => group._hiveMindShim === HIVE_MIND_MARKER, ); expect(markedGroups).toHaveLength(1); expect(markedGroups?.[0].hooks[0]).toEqual({ type: 'command', command: newCommand, timeout: 7, }); }); it('recognizes and replaces a hook after Claude strips the ownership marker', () => { const oldCommand = hookCommandFor('/old/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/old/cli.js'); const newCommand = hookCommandFor('/new/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/new/cli.js'); const installed = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]); const normalized = structuredClone(installed); delete normalized.hooks?.SessionStart?.[0]._hiveMindShim; expect(hasHiveHooks(normalized)).toBe(true); const upgraded = mergeHiveHooks(normalized, [{ basename: 'session-start', command: newCommand, timeout: 7 }]); expect(upgraded.hooks?.SessionStart).toHaveLength(1); expect(upgraded.hooks?.SessionStart?.[0].hooks[0].command).toBe(newCommand); }); it('does not claim an unrelated command that only mentions a Waggle hook path', () => { const unrelated: ClaudeCodeSettings = { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo "C:\\archive\\hive-mind-hooks-claude-code\\dist\\hooks\\session-start.js"', }], }], }, }; expect(hasHiveHooks(unrelated)).toBe(false); const command = hookCommandFor( '/new/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/new/cli.js', ); const merged = mergeHiveHooks(unrelated, [{ basename: 'session-start', command, timeout: 5 }]); expect(merged.hooks?.SessionStart).toHaveLength(2); expect(merged.hooks?.SessionStart?.[0]).toEqual(unrelated.hooks?.SessionStart?.[0]); }); it('preserves unrelated top-level fields', () => { const merged = mergeHiveHooks( { env: { SOMETHING: '1' }, statusLine: { type: 'command', command: 'foo' }, hooks: {} } as ClaudeCodeSettings, [makeEntry('session-start')], ); expect(merged['env']).toEqual({ SOMETHING: '1' }); expect(merged['statusLine']).toEqual({ type: 'command', command: 'foo' }); }); }); describe('hasHiveHooks', () => { it('returns false on empty settings', () => { expect(hasHiveHooks(undefined)).toBe(false); expect(hasHiveHooks({})).toBe(false); expect(hasHiveHooks({ hooks: {} })).toBe(false); }); it('returns true when at least one event array has the marker', () => { const merged = mergeHiveHooks({}, [makeEntry('stop')]); expect(hasHiveHooks(merged)).toBe(true); }); }); describe('defaultHookEntries', () => { it('builds 4 entries — one per canonical hook', () => { const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor); expect(entries).toHaveLength(4); const events = entries.map((e) => HOOK_EVENT_BY_BASENAME[e.basename]); expect([...events].sort()).toEqual(['PreCompact', 'SessionStart', 'Stop', 'UserPromptSubmit']); }); it('every entry carries the requested timeout', () => { const entries = defaultHookEntries(HOOKS_DIR, 9, hookCommandFor); expect(entries.every((e) => e.timeout === 9)).toBe(true); }); it('threads cliPath into every generated hook command', () => { const cliPath = '/abs/cli.js'; const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor, cliPath); expect(entries.every((e) => e.command.includes(`--cli-path "${cliPath}"`))).toBe(true); }); it('omits --cli-path entirely when none supplied', () => { const entries = defaultHookEntries(HOOKS_DIR, 5, hookCommandFor); expect(entries.every((e) => !e.command.includes('--cli-path'))).toBe(true); }); });