import { describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; import { HIVE_MIND_MARKER, HOOKS_KEY, hasHiveEntries, isHiveEntry, parseConfig, serializeConfig, yamlRegister, yamlUnregister, type HermesRegisterEntry, } from '../src/yaml-merger.js'; function entry(eventKey: string, command: string, timeout = 60): HermesRegisterEntry { return { eventKey, command, timeout }; } /** The four register entries hermes install builds (SessionStart is 2-key). */ const ALL_ENTRIES: readonly HermesRegisterEntry[] = [ entry('on_session_start', 'node "/abs/dist/hooks/session-start.js"'), entry('pre_llm_call', 'node "/abs/dist/hooks/session-start.js"'), entry('pre_llm_call', 'node "/abs/dist/hooks/user-prompt-submit.js"'), entry('post_llm_call', 'node "/abs/dist/hooks/stop.js"'), ]; function eventArray(config: Record, eventKey: string): Record[] { const hooks = config[HOOKS_KEY] as Record | undefined; return (hooks?.[eventKey] as Record[] | undefined) ?? []; } describe('parseConfig (hermes YAML codec)', () => { it('returns {} for an empty / whitespace-only config (create-if-missing)', () => { expect(parseConfig('')).toEqual({}); expect(parseConfig(' \n ')).toEqual({}); }); it('parses a realistic config.yaml WITH comments + a pre-existing user hook', () => { const raw = [ '# Hermes CLI config', 'model: claude-opus', 'hooks:', ' # a user-installed lint hook', ' pre_llm_call:', ' - command: node /user/own/lint.js', ' timeout: 30', 'hooks_auto_accept: false', '', ].join('\n'); const parsed = parseConfig(raw); expect(parsed['model']).toBe('claude-opus'); expect(parsed['hooks_auto_accept']).toBe(false); const arr = eventArray(parsed, 'pre_llm_call'); expect(arr).toHaveLength(1); expect(arr[0]['command']).toBe('node /user/own/lint.js'); expect(arr[0]['timeout']).toBe(30); }); it('throws on malformed YAML so the installer fails loudly', () => { // A block-mapping value that cannot parse (tab indentation / bad structure). expect(() => parseConfig('hooks:\n\t- : : :\n bad')).toThrow(/parse/i); }); it('treats a top-level scalar/array YAML doc as empty (not a crash)', () => { expect(parseConfig('"just a string"')).toEqual({}); expect(parseConfig('- a\n- b\n')).toEqual({}); }); }); describe('serializeConfig (hermes YAML codec)', () => { it('round-trips a merged config back to parseable YAML with a trailing newline', () => { const merged = yamlRegister({ model: 'x' }, [entry('post_llm_call', 'node /a/stop.js')]); const text = serializeConfig(merged); expect(text.endsWith('\n')).toBe(true); const reparsed = parseYaml(text) as Record; expect(reparsed['model']).toBe('x'); expect(eventArray(reparsed, 'post_llm_call')).toHaveLength(1); }); }); describe('yamlRegister (hermes flat {command,timeout,_hive_mind} entry shape)', () => { it('returns a NEW object — does not mutate input (immutability contract)', () => { const original: Record = { hooks: { pre_llm_call: [] } }; const merged = yamlRegister(original, [entry('pre_llm_call', 'node /a/x.js')]); expect(merged).not.toBe(original); // Input untouched. expect((original['hooks'] as Record)['pre_llm_call']).toEqual([]); }); it('registers a marker-tagged entry under each supplied native event key', () => { const merged = yamlRegister({}, ALL_ENTRIES); expect(eventArray(merged, 'on_session_start')).toHaveLength(1); // pre_llm_call carries BOTH the session-start inject hook and the user-prompt hook. expect(eventArray(merged, 'pre_llm_call')).toHaveLength(2); expect(eventArray(merged, 'post_llm_call')).toHaveLength(1); }); it('builds the flat entry shape: {command, timeout, _hive_mind marker}', () => { const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js', 9)]); const e = eventArray(merged, 'post_llm_call')[0]; expect(e['command']).toBe('node /a/stop.js'); expect(e['timeout']).toBe(9); expect(e['_hive_mind']).toBe(HIVE_MIND_MARKER); // No nested matcher/hooks wrapper — matcher is stripped-with-warning on // lifecycle events, so we never set it. expect(e['matcher']).toBeUndefined(); expect(e['hooks']).toBeUndefined(); }); it('preserves existing (user) hook entries verbatim — additive merge', () => { const existing: Record = { hooks: { pre_llm_call: [ { command: 'node /user/own.js', timeout: 30 }, ], }, }; const merged = yamlRegister(existing, [entry('pre_llm_call', 'node /a/session-start.js')]); const arr = eventArray(merged, 'pre_llm_call'); expect(arr).toHaveLength(2); expect(arr[0]['command']).toBe('node /user/own.js'); expect(arr[0]['_hive_mind']).toBeUndefined(); expect(arr[1]['_hive_mind']).toBe(HIVE_MIND_MARKER); }); it('preserves unrelated top-level (non-hooks) YAML keys', () => { const merged = yamlRegister( { model: 'claude-opus', temperature: 0.2, hooks: {} }, [entry('pre_llm_call', 'node /a/x.js')], ); expect(merged['model']).toBe('claude-opus'); expect(merged['temperature']).toBe(0.2); }); it('replaces our own marker-tagged entry on re-install (dedup by command, in place)', () => { const e = entry('pre_llm_call', 'node /a/session-start.js', 60); const merged1 = yamlRegister({}, [e]); const merged2 = yamlRegister(merged1, [{ ...e, timeout: 120 }]); const arr = eventArray(merged2, 'pre_llm_call'); expect(arr).toHaveLength(1); // never duplicated expect(arr[0]['timeout']).toBe(120); expect(arr[0]['_hive_mind']).toBe(HIVE_MIND_MARKER); }); it('re-registering the full set keeps each pre_llm_call slot at exactly 2 hive entries', () => { const merged1 = yamlRegister({}, ALL_ENTRIES); const merged2 = yamlRegister(merged1, ALL_ENTRIES); expect(eventArray(merged2, 'pre_llm_call')).toHaveLength(2); expect(eventArray(merged2, 'on_session_start')).toHaveLength(1); expect(eventArray(merged2, 'post_llm_call')).toHaveLength(1); }); it('does not collapse two DIFFERENT hive commands sharing one event key', () => { // session-start inject + user-prompt both ride pre_llm_call with distinct // commands — they must coexist, not dedup each other. const merged = yamlRegister({}, [ entry('pre_llm_call', 'node /a/session-start.js'), entry('pre_llm_call', 'node /a/user-prompt-submit.js'), ]); const arr = eventArray(merged, 'pre_llm_call'); expect(arr).toHaveLength(2); expect(arr.map((e) => e['command']).sort()).toEqual([ 'node /a/session-start.js', 'node /a/user-prompt-submit.js', ]); }); }); describe('isHiveEntry (hermes structural marker)', () => { it('true only for entries carrying the structural _hive_mind sentinel', () => { expect(isHiveEntry({ command: 'x', _hive_mind: HIVE_MIND_MARKER })).toBe(true); expect(isHiveEntry({ command: 'x' })).toBe(false); expect(isHiveEntry({ command: 'x', _hive_mind: 'someone-else' })).toBe(false); expect(isHiveEntry(undefined)).toBe(false); expect(isHiveEntry('not-an-object')).toBe(false); }); }); describe('hasHiveEntries (hermes)', () => { it('false on empty / hookless config', () => { expect(hasHiveEntries(undefined)).toBe(false); expect(hasHiveEntries({})).toBe(false); expect(hasHiveEntries({ hooks: {} })).toBe(false); }); it('true once a marker-tagged entry is present', () => { const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js')]); expect(hasHiveEntries(merged)).toBe(true); }); it('false for a config holding ONLY non-hive (user) entries', () => { const userOnly: Record = { hooks: { post_llm_call: [{ command: 'node /user/own.js', timeout: 5 }] }, }; expect(hasHiveEntries(userOnly)).toBe(false); }); }); describe('yamlUnregister (hermes — used for diagnostics / backup-less path)', () => { it('strips exactly our marker-tagged entries, preserves user entries', () => { const userEntry = { command: 'node /user/own.js', timeout: 5 }; const withUser: Record = { hooks: { post_llm_call: [userEntry] } }; const merged = yamlRegister(withUser, [entry('post_llm_call', 'node /a/stop.js')]); expect(eventArray(merged, 'post_llm_call')).toHaveLength(2); const stripped = yamlUnregister(merged); const arr = eventArray(stripped, 'post_llm_call'); expect(arr).toHaveLength(1); expect(arr[0]['command']).toBe('node /user/own.js'); expect(hasHiveEntries(stripped)).toBe(false); }); it('returns a NEW object and leaves the input untouched (immutability)', () => { const merged = yamlRegister({}, [entry('post_llm_call', 'node /a/stop.js')]); const stripped = yamlUnregister(merged); expect(stripped).not.toBe(merged); expect(hasHiveEntries(merged)).toBe(true); // original still has the entry }); it('is a no-op (new object) when there is no hooks block', () => { const stripped = yamlUnregister({ model: 'x' }); expect(stripped['model']).toBe('x'); expect(hasHiveEntries(stripped)).toBe(false); }); });