import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { installManagedSessionHook, removeManagedSessionHook, MARKER_KEY } from '../../src/utils/sessionhook.js'; describe('sessionhook', () => { let tmp: string; beforeEach(async () => { tmp = await mkdtemp(join(tmpdir(), 'mcpctl-sessionhook-')); }); afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); it('creates settings.json from scratch when missing', async () => { const path = join(tmp, 'settings.json'); const result = await installManagedSessionHook('mcpctl skills sync --quiet', path); expect(result.updated).toBe(true); const settings = JSON.parse(await readFile(path, 'utf-8')); expect(settings.hooks.SessionStart).toHaveLength(1); const entry = settings.hooks.SessionStart[0].hooks[0]; expect(entry.command).toBe('mcpctl skills sync --quiet'); expect(entry[MARKER_KEY]).toBe(true); }); it('is idempotent — re-running does not add duplicates', async () => { const path = join(tmp, 'settings.json'); await installManagedSessionHook('mcpctl skills sync --quiet', path); const second = await installManagedSessionHook('mcpctl skills sync --quiet', path); expect(second.updated).toBe(false); const settings = JSON.parse(await readFile(path, 'utf-8')); const entries = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); const managed = entries.filter((e: Record) => e[MARKER_KEY] === true); expect(managed).toHaveLength(1); }); it('updates the command in place when it changes', async () => { const path = join(tmp, 'settings.json'); await installManagedSessionHook('mcpctl skills sync', path); const updated = await installManagedSessionHook('mcpctl skills sync --quiet', path); expect(updated.updated).toBe(true); const settings = JSON.parse(await readFile(path, 'utf-8')); const managed = settings.hooks.SessionStart .flatMap((g: { hooks: unknown[] }) => g.hooks) .find((e: Record) => e[MARKER_KEY] === true); expect(managed.command).toBe('mcpctl skills sync --quiet'); }); it('preserves non-managed hooks', async () => { const path = join(tmp, 'settings.json'); await mkdir(tmp, { recursive: true }); await writeFile(path, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-hook' }] }], }, })); await installManagedSessionHook('mcpctl skills sync --quiet', path); const settings = JSON.parse(await readFile(path, 'utf-8')); const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); expect(all).toHaveLength(2); expect(all.find((e: Record) => e.command === 'echo user-hook')).toBeDefined(); expect(all.find((e: Record) => e[MARKER_KEY] === true)).toBeDefined(); }); it('remove drops the managed entry but keeps user hooks', async () => { const path = join(tmp, 'settings.json'); await writeFile(path, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }], }, })); await installManagedSessionHook('mcpctl skills sync --quiet', path); const removed = await removeManagedSessionHook(path); expect(removed.removed).toBe(true); const settings = JSON.parse(await readFile(path, 'utf-8')); const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); expect(all).toHaveLength(1); expect(all[0].command).toBe('echo user'); }); it('remove is a no-op when no managed entry exists', async () => { const path = join(tmp, 'settings.json'); const result = await removeManagedSessionHook(path); expect(result.removed).toBe(false); }); it('survives empty settings.json', async () => { const path = join(tmp, 'settings.json'); await writeFile(path, ''); await installManagedSessionHook('mcpctl skills sync --quiet', path); const settings = JSON.parse(await readFile(path, 'utf-8')); expect(settings.hooks.SessionStart).toHaveLength(1); }); it('strips line comments before parsing', async () => { const path = join(tmp, 'settings.json'); await writeFile(path, '// a leading comment\n{\n "hooks": {}\n}\n'); await installManagedSessionHook('mcpctl skills sync --quiet', path); const settings = JSON.parse(await readFile(path, 'utf-8')); expect(settings.hooks.SessionStart).toHaveLength(1); }); }); describe('untagged duplicates of the managed hook', () => { let tmp2: string; let settings: string; beforeEach(async () => { tmp2 = await mkdtemp(join(tmpdir(), 'mcpctl-hook-dupe-')); settings = join(tmp2, 'settings.json'); }); afterEach(async () => { await rm(tmp2, { recursive: true, force: true }); }); it('removes an identical row left behind before the marker existed', async () => { // Exactly the shape found in a real ~/.claude: one tagged row, one not. // Invisible in the UI; it just runs the sync twice every session. await writeFile(settings, JSON.stringify({ hooks: { SessionStart: [ { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet' }] }, { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }] }, ], }, })); const { updated } = await installManagedSessionHook('mcpctl skills sync --quiet', settings); expect(updated).toBe(true); const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { hooks: { SessionStart: Array<{ hooks: Array> }> }; }; const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks); expect(rows).toEqual([{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }]); }); it('leaves a hook the user wrote alone, even one that also calls mcpctl', async () => { await writeFile(settings, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'mcpctl skills sync --project mine' }] }], }, })); await installManagedSessionHook('mcpctl skills sync --quiet', settings); const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { hooks: { SessionStart: Array<{ hooks: Array<{ command: string }> }> }; }; const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks).map((r) => r.command); expect(rows).toContain('mcpctl skills sync --project mine'); }); });