moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import { vi } from 'vitest';
import type { CliBridge, MemoryHit } from '@waggle/hive-mind-shim-core';
export interface MockBridgeOverrides {
saveMemoryResult?: { id: string; success: boolean; workspace: string };
recallMemoryHits?: MemoryHit[];
cleanupFramesResult?: { pruned: number };
saveMemoryThrows?: Error;
}
export interface MockBridge extends CliBridge {
saveMemory: ReturnType<typeof vi.fn>;
recallMemory: ReturnType<typeof vi.fn>;
cleanupFrames: ReturnType<typeof vi.fn>;
callMcpTool: ReturnType<typeof vi.fn>;
setWorkspaceById: ReturnType<typeof vi.fn>;
getActiveWorkspaceId: ReturnType<typeof vi.fn>;
}
/**
* Mirrors the codex sibling test helper (tests/hooks/_test-helpers.ts). The
* cursor hooks drive the SAME shared `runHook` contract, so the same
* injectable CliBridge mock + stdout/exit captures apply unchanged.
*/
export function makeMockBridge(overrides: MockBridgeOverrides = {}): MockBridge {
let activeWorkspaceId: string | undefined;
const saveMemory = overrides.saveMemoryThrows
? vi.fn(async () => { throw overrides.saveMemoryThrows; })
: vi.fn(async () => overrides.saveMemoryResult ?? { id: 'frame-1', success: true, workspace: 'personal' });
const recallMemory = vi.fn(async () => overrides.recallMemoryHits ?? []);
const cleanupFrames = vi.fn(async () => overrides.cleanupFramesResult ?? { pruned: 0 });
const callMcpTool = vi.fn(async () => ({}));
const setWorkspaceById = vi.fn((id?: string) => { activeWorkspaceId = id; });
const getActiveWorkspaceId = vi.fn(() => activeWorkspaceId);
return {
saveMemory,
recallMemory,
cleanupFrames,
callMcpTool,
setWorkspaceById,
getActiveWorkspaceId,
} as unknown as MockBridge;
}
export interface CapturedHookOutput {
stdout: string[];
exits: number[];
}
export function makeHookCaptures(): CapturedHookOutput & {
writeStdout: (s: string) => void;
exit: (code: number) => void;
} {
const stdout: string[] = [];
const exits: number[] = [];
return {
stdout,
exits,
writeStdout: (s) => stdout.push(s),
exit: (c) => exits.push(c),
};
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { runPreCompact } from '../../src/hooks/pre-compact.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
describe('cursor pre-compact handler (preCompact — observational, fire-and-forget)', () => {
it('calls cleanupFrames to merge superseded frames before host compaction', async () => {
const bridge = makeMockBridge({ cleanupFramesResult: { pruned: 4 } });
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => JSON.stringify({ conversation_id: 'conv-3' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.cleanupFrames).toHaveBeenCalledTimes(1);
expect(cap.exits).toEqual([0]);
});
it('still calls cleanupFrames even when no scope/session present', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.cleanupFrames).toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 when cleanupFrames rejects', async () => {
const bridge = makeMockBridge();
bridge.cleanupFrames.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runPreCompact({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { runSessionStart } from '../../src/hooks/session-start.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { MemoryHit } from '@waggle/hive-mind-shim-core';
const HIT_FIXTURE: MemoryHit = {
id: 1,
content: '[hm src:cursor event:stop] past observation',
importance: 'important',
source: 'system',
score: 0.87,
created_at: '2026-04-28T10:00:00.000Z',
from: 'personal',
};
describe('cursor session-start handler', () => {
it('recalls personal-scoped frames and injects them as { additional_context } (cursor rename)', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [HIT_FIXTURE] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => JSON.stringify({ cwd: '/proj/x', recall_limit: 1 }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.recallMemory).toHaveBeenCalledWith('', { limit: 1, scope: 'personal', workspace: null });
expect(cap.stdout).toHaveLength(1);
const parsed = JSON.parse(cap.stdout[0]) as Record<string, unknown>;
// Cursor's sessionStart inject shape is { additional_context }, a rename of
// CC's hookSpecificOutput.additionalContext — assert the rename, and that
// the default CC envelope is NOT used.
expect(parsed['hookSpecificOutput']).toBeUndefined();
expect(typeof parsed['additional_context']).toBe('string');
expect(parsed['additional_context'] as string).toContain('past observation');
expect(cap.exits).toEqual([0]);
});
it('handles an empty recall result gracefully (still { additional_context })', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const parsed = JSON.parse(cap.stdout[0]) as { additional_context: string };
expect(parsed.additional_context).toContain('no recalled frames');
expect(cap.exits).toEqual([0]);
});
it('annotates hits with their workspace origin when from != personal', async () => {
const bridge = makeMockBridge({ recallMemoryHits: [{ ...HIT_FIXTURE, from: 'workspace:team-foo' }] });
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const parsed = JSON.parse(cap.stdout[0]) as { additional_context: string };
expect(parsed.additional_context).toContain('workspace:team-foo');
});
it('FAIL-OPEN: exits 0 even when the bridge throws', async () => {
const bridge = makeMockBridge();
bridge.recallMemory.mockRejectedValueOnce(new Error('cli unreachable'));
const cap = makeHookCaptures();
await runSessionStart({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,190 @@
import { describe, expect, it, afterEach, vi } from 'vitest';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runStop } from '../../src/hooks/stop.js';
import { cursorAdapter } from '../../src/adapter.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { HookFrame } from '@waggle/hive-mind-shim-core';
describe('cursor stop handler (turn read via transcript_path)', () => {
const dirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true });
});
async function writeTranscript(text: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'hmcur-stop-'));
dirs.push(dir);
const file = join(dir, 'transcript.txt');
await writeFile(file, text, 'utf-8');
return file;
}
it('reads the completed turn off transcript_path and saves an important frame', async () => {
const transcriptPath = await writeTranscript(
'Here is the answer to your question about X. It depends on the config.',
);
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
transcript_path: transcriptPath,
cwd: '/proj/foo',
conversation_id: 'conv-9',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.source).toBe('cursor');
expect(frame.scope).toBe('conv-9');
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.content.length).toBeGreaterThan(0);
expect(cap.exits).toEqual([0]);
});
it('TOLERATE-NULL: a missing transcript_path file → no save, NO throw, exits 0 (fail open)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
transcript_path: join(tmpdir(), 'does-not-exist-hmcur', 'nope.txt'),
conversation_id: 'conv-1',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
// No readable response → shared body skips the save; the hook still exits 0.
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('TOLERATE-NULL: no transcript_path at all (transcripts disabled) → no save, exits 0', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ conversation_id: 'conv-2' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('prefers an inline response key over the transcript file (forward-compat)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
response: 'inline assistant message wins',
transcript_path: '/should/not/be/read.txt',
conversation_id: 's',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.content.length).toBeGreaterThan(0);
});
it('links the Stop frame to its parent prompt frame when known', async () => {
const transcriptPath = await writeTranscript('done with the task');
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({
transcript_path: transcriptPath,
parent_frame_id: 'frame-prompt-1',
conversation_id: 's',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.parent).toBe('frame-prompt-1');
});
it('does NOT emit a discovery signal by default (WAGGLE_SIGNAL_EMIT off)', async () => {
vi.stubEnv('WAGGLE_SIGNAL_EMIT', '');
const transcriptPath = await writeTranscript('hi there');
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ transcript_path: transcriptPath, conversation_id: 's' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.callMcpTool).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 even when saveMemory rejects', async () => {
const transcriptPath = await writeTranscript('some answer');
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli unreachable') });
const cap = makeHookCaptures();
await runStop({
readStdin: async () => JSON.stringify({ transcript_path: transcriptPath, conversation_id: 's' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});
// Direct unit coverage of the async transcript reader, including the
// ctx.readFile preference path (which the runHook drive path never supplies).
describe('cursorAdapter.extractResponse (transcript reader)', () => {
it('prefers ctx.readFile over node fs when supplied', async () => {
const readFile = vi.fn(async () => ' injected transcript text ');
const out = await cursorAdapter.extractResponse(
{ transcript_path: '/whatever/path.jsonl' },
{ readFile },
);
expect(readFile).toHaveBeenCalledWith('/whatever/path.jsonl');
expect(out).toBe('injected transcript text'); // trimmed
});
it('returns the inline response key without touching the file reader', async () => {
const readFile = vi.fn(async () => 'should not be read');
const out = await cursorAdapter.extractResponse(
{ response: 'inline wins', transcript_path: '/x.txt' },
{ readFile },
);
expect(out).toBe('inline wins');
expect(readFile).not.toHaveBeenCalled();
});
it('returns undefined when there is no transcript_path and no inline response', async () => {
const out = await cursorAdapter.extractResponse({ conversation_id: 's' }, {});
expect(out).toBeUndefined();
});
it('FAILS OPEN: a throwing reader yields undefined, never rejects', async () => {
const readFile = vi.fn(async () => { throw new Error('EACCES'); });
const out = await cursorAdapter.extractResponse(
{ transcript_path: '/locked.txt' },
{ readFile },
);
expect(out).toBeUndefined();
});
it('treats an empty/whitespace transcript as undefined (not an empty save)', async () => {
const readFile = vi.fn(async () => ' \n ');
const out = await cursorAdapter.extractResponse(
{ transcript_path: '/empty.txt' },
{ readFile },
);
expect(out).toBeUndefined();
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { runUserPromptSubmit } from '../../src/hooks/user-prompt-submit.js';
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
import type { HookFrame } from '@waggle/hive-mind-shim-core';
describe('cursor user-prompt-submit handler (SAVE-ONLY — beforeSubmitPrompt)', () => {
it('saves a temporary, cursor-sourced frame containing the prompt', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({
prompt: 'How do I X?',
cwd: '/proj/foo',
conversation_id: 'conv-7',
}),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame).toMatchObject({
content: 'How do I X?',
importance: 'temporary',
scope: 'conv-7',
source: 'cursor',
});
expect(cap.exits).toEqual([0]);
});
it('SAVE-ONLY: emits NO stdout (beforeSubmitPrompt cannot inject)', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ prompt: 'hi', conversation_id: 'c1' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
// The shared UserPromptSubmit body returns undefined → runHook writes nothing.
expect(cap.stdout).toHaveLength(0);
expect(cap.exits).toEqual([0]);
});
it('reads the cursor user_message fallback when prompt is absent', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ user_message: 'hello from cursor', conversation_id: 's1' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
const frame = bridge.saveMemory.mock.calls[0][0] as HookFrame;
expect(frame.content).toBe('hello from cursor');
expect(frame.source).toBe('cursor');
});
it('skips the save when no prompt is present', async () => {
const bridge = makeMockBridge();
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => '{}',
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(bridge.saveMemory).not.toHaveBeenCalled();
expect(cap.exits).toEqual([0]);
});
it('FAIL-OPEN: exits 0 even if saveMemory rejects', async () => {
const bridge = makeMockBridge({ saveMemoryThrows: new Error('cli down') });
const cap = makeHookCaptures();
await runUserPromptSubmit({
readStdin: async () => JSON.stringify({ prompt: 'x' }),
writeStdout: cap.writeStdout,
exit: cap.exit,
bridge,
});
expect(cap.exits).toEqual([0]);
});
});

View File

@@ -0,0 +1,175 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { install } from '../src/install.js';
import { HIVE_MIND_MARKER } from '../src/adapter.js';
interface TestEnv {
home: string;
hooksDir: string;
configPath: string;
pointerPath: string;
}
/** Cursor hooks.json is OPTIONAL — `withConfig=false` exercises create-if-missing. */
async function bootstrap(initial: Record<string, unknown> | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmcur-install-'));
const cursorDir = join(home, '.cursor');
await mkdir(cursorDir, { recursive: true });
const configPath = join(cursorDir, 'hooks.json');
if (initial !== undefined) {
await writeFile(configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, configPath, pointerPath: join(cursorDir, 'hive-mind-install.json') };
}
describe('install (cursor)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws on malformed JSON in an existing hooks.json', async () => {
env = await bootstrap({});
await writeFile(env.configPath, '{ not valid json', 'utf-8');
await expect(install({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/parse/);
});
// ── pre-existed branch ────────────────────────────────────────────────
it('writes a byte-identical backup before mutating a pre-existing hooks.json', async () => {
env = await bootstrap({ version: 1, hooks: {} });
const original = await readFile(env.configPath, 'utf-8');
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.backupPath).not.toBeNull();
const backupContent = await readFile(result.backupPath as string, 'utf-8');
expect(backupContent).toBe(original);
});
it('records created_by_us=false when hooks.json pre-existed', async () => {
env = await bootstrap({ version: 1, hooks: {} });
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.createdByUs).toBe(false);
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['created_by_us']).toBe(false);
expect(pointer['settings_backup']).toBe(result.backupPath);
});
it('appends 4 hive groups under the RENAMED cursor event keys + preserves existing structure', async () => {
const initial = {
version: 1,
hooks: {
sessionStart: [
{ command: 'node /existing/x.js', type: 'command', timeout: 10 },
],
},
};
env = await bootstrap(initial);
await install({ home: env.home, hooksDir: env.hooksDir });
const after = JSON.parse(await readFile(env.configPath, 'utf-8')) as {
version: number;
hooks: Record<string, Array<{ _hiveMindShim?: string; command: string; type: string }>>;
};
expect(after.hooks.sessionStart).toHaveLength(2);
expect(after.hooks.sessionStart[0].command).toBe('node /existing/x.js');
expect(after.hooks.sessionStart[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
expect(after.hooks.sessionStart[1].type).toBe('command');
// Renamed events present (NOT the CC PascalCase names).
expect(after.hooks.beforeSubmitPrompt).toHaveLength(1);
expect(after.hooks.stop).toHaveLength(1);
expect(after.hooks.preCompact).toHaveLength(1);
});
it('does NOT clobber a user-set version on a pre-existing config', async () => {
env = await bootstrap({ version: 7, hooks: {} });
await install({ home: env.home, hooksDir: env.hooksDir });
const after = JSON.parse(await readFile(env.configPath, 'utf-8')) as { version: number };
expect(after.version).toBe(7);
});
// ── create-if-missing branch ──────────────────────────────────────────
it('creates a skeleton {version:1, hooks:{...}} when hooks.json is absent', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(env.configPath)).toBe(true);
const after = JSON.parse(await readFile(env.configPath, 'utf-8')) as {
version: number;
hooks: Record<string, unknown>;
};
expect(after.version).toBe(1);
expect(after.hooks).toBeDefined();
expect(Object.keys(after.hooks).sort()).toEqual([
'beforeSubmitPrompt',
'preCompact',
'sessionStart',
'stop',
]);
expect(result.createdByUs).toBe(true);
});
it('records created_by_us=true and writes NO backup when hooks.json is absent', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.backupPath).toBeNull();
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['created_by_us']).toBe(true);
expect(pointer['settings_backup']).toBeNull();
});
// ── pointer + cli-path ───────────────────────────────────────────────
it('drops a pointer file with installed_hooks + version', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.pointerPath)).toBe(true);
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
expect(typeof pointer['version']).toBe('string');
});
it('respects a custom now() for a deterministic backup filename', async () => {
env = await bootstrap({ version: 1, hooks: {} });
const fixedTs = '2026-04-28T10:30:45.123Z';
const result = await install({
home: env.home,
hooksDir: env.hooksDir,
now: () => new Date(fixedTs),
});
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
const stats = await stat(result.backupPath as string);
expect(stats.isFile()).toBe(true);
});
it('threads --cli-path into every generated hook command + records it in the pointer', async () => {
env = await bootstrap(undefined);
const cliPath = '/abs/path/to/dist/index.js';
const result = await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
expect(result.cliPath).toBe(cliPath);
const after = JSON.parse(await readFile(env.configPath, 'utf-8')) as {
hooks: Record<string, Array<{ command: string }>>;
};
expect(after.hooks.sessionStart[0].command).toContain(`--cli-path "${cliPath}"`);
expect(after.hooks.stop[0].command).toContain(`--cli-path "${cliPath}"`);
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(pointer['cli_path']).toBe(cliPath);
});
it('rejects --cli-path values containing double-quote characters', async () => {
env = await bootstrap(undefined);
await expect(install({
home: env.home,
hooksDir: env.hooksDir,
cliPath: 'malicious" && rm -rf / "',
})).rejects.toThrow(/double-quote/);
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { join, resolve } from 'node:path';
import {
allHookBasenames,
backupPathFor,
hookCommandFor,
resolvePaths,
} from '../src/paths.js';
describe('resolvePaths (cursor)', () => {
it('places hooks.json + pointer under <home>/.cursor/', () => {
const home = resolve('/fake/home');
const paths = resolvePaths({ home, hooksDir: resolve('/some/dist/hooks') });
expect(paths.cursorDir).toBe(join(home, '.cursor'));
// Cursor targets a STANDALONE hooks.json — NOT settings.json (editor prefs).
expect(paths.configPath).toBe(join(home, '.cursor', 'hooks.json'));
expect(paths.pointerPath).toBe(join(home, '.cursor', 'hive-mind-install.json'));
});
it('hooksDir override wins over moduleUrl', () => {
const explicit = resolve('/x/y/hooks');
const paths = resolvePaths({
home: resolve('/h'),
hooksDir: explicit,
moduleUrl: 'file:///irrelevant/dist/install.js',
});
expect(paths.hooksDir).toBe(explicit);
});
it('falls back to cwd/dist/hooks when neither moduleUrl nor hooksDir is given', () => {
const paths = resolvePaths({ home: resolve('/h') });
expect(paths.hooksDir).toBe(resolve(process.cwd(), 'dist', 'hooks'));
});
});
describe('hookCommandFor (cursor, 2-arg shared helper)', () => {
it('produces a quoted node invocation around the absolute script path', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'));
expect(cmd).toMatch(/^node "[^"]+session-start\.js"$/);
});
it('appends --cli-path when supplied', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '/abs/cli/dist/index.js');
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
});
it('omits --cli-path when empty string is passed', () => {
const cmd = hookCommandFor(resolve('/abs/dist/hooks/session-start.js'), '');
expect(cmd).not.toContain('--cli-path');
});
it('preserves Windows-style paths (with spaces) inside the quotes', () => {
const cmd = hookCommandFor('/abs/dist/hooks/stop.js', 'C:\\Program Files\\hive-mind\\dist\\index.js');
expect(cmd).toContain('--cli-path "C:\\Program Files\\hive-mind\\dist\\index.js"');
});
});
describe('backupPathFor (cursor)', () => {
it('replaces colons and dots in the timestamp for filesystem safety', () => {
const backup = backupPathFor('/h/.cursor/hooks.json', '2026-04-28T10:30:45.123Z');
expect(backup).toBe('/h/.cursor/hooks.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
});
});
describe('allHookBasenames (cursor)', () => {
it('returns the four canonical basenames', () => {
expect([...allHookBasenames()].sort()).toEqual([
'pre-compact',
'session-start',
'stop',
'user-prompt-submit',
]);
});
});

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import {
jsonRegister,
jsonUnregister,
hasHiveEntries,
type JsonRegisterEntry,
} from '@waggle/hive-mind-hooks-core';
import {
cursorRegisterSpec,
HIVE_MIND_MARKER,
CURSOR_EVENT_NAME,
} from '../src/adapter.js';
import { hookCommandFor } from '../src/paths.js';
const HOOKS_DIR = '/abs/dist/hooks';
function entry(
lifecycle: JsonRegisterEntry['lifecycle'],
basename: string,
timeout = 5,
): JsonRegisterEntry {
return {
lifecycle,
command: hookCommandFor(`${HOOKS_DIR}/${basename}.js`),
timeout,
};
}
const ALL_ENTRIES: readonly JsonRegisterEntry[] = [
entry('session-start', 'session-start'),
entry('user-prompt-submit', 'user-prompt-submit'),
entry('stop', 'stop'),
entry('pre-compact', 'pre-compact'),
];
interface CursorGroup {
command: string;
type: string;
timeout: number;
_hiveMindShim?: string;
}
function groupsAt(config: Record<string, unknown>, eventKey: string): CursorGroup[] {
const hooks = config['hooks'] as Record<string, unknown> | undefined;
return (hooks?.[eventKey] as CursorGroup[] | undefined) ?? [];
}
describe('CURSOR_EVENT_NAME (field renames)', () => {
it('renames the four lifecycle events to cursor native keys', () => {
expect(CURSOR_EVENT_NAME).toEqual({
'session-start': 'sessionStart',
'user-prompt-submit': 'beforeSubmitPrompt',
'stop': 'stop',
'pre-compact': 'preCompact',
});
});
});
describe('jsonRegister (cursor FLAT {command,type,timeout} group shape)', () => {
it('returns a NEW object — does not mutate input (immutability contract)', () => {
const original: Record<string, unknown> = { version: 1, hooks: { sessionStart: [] } };
const merged = jsonRegister(original, [entry('session-start', 'session-start')], cursorRegisterSpec);
expect(merged).not.toBe(original);
// Input untouched.
expect((original['hooks'] as Record<string, unknown>)['sessionStart']).toEqual([]);
});
it('registers a group under each renamed cursor event key', () => {
const merged = jsonRegister({}, ALL_ENTRIES, cursorRegisterSpec);
expect(groupsAt(merged, 'sessionStart')).toHaveLength(1);
expect(groupsAt(merged, 'beforeSubmitPrompt')).toHaveLength(1);
expect(groupsAt(merged, 'stop')).toHaveLength(1);
expect(groupsAt(merged, 'preCompact')).toHaveLength(1);
});
it('builds the FLAT cursor group shape: {command,type:command,timeout,marker} — NO {matcher,hooks:[]} wrapper', () => {
const merged = jsonRegister({}, [entry('stop', 'stop', 9)], cursorRegisterSpec);
const g = groupsAt(merged, 'stop')[0];
expect(g._hiveMindShim).toBe(HIVE_MIND_MARKER);
expect(g.type).toBe('command');
expect(g.command).toContain('stop.js');
expect(g.timeout).toBe(9);
// FLAT shape — there is no nested `hooks` array like codex.
expect((g as unknown as Record<string, unknown>)['hooks']).toBeUndefined();
expect((g as unknown as Record<string, unknown>)['matcher']).toBeUndefined();
});
it('seeds version:1 on a fresh config (ensureSkeleton)', () => {
const merged = jsonRegister({}, [entry('stop', 'stop')], cursorRegisterSpec);
expect(merged['version']).toBe(1);
});
it('never clobbers a user-set version', () => {
const merged = jsonRegister({ version: 3 }, [entry('stop', 'stop')], cursorRegisterSpec);
expect(merged['version']).toBe(3);
});
it('preserves existing (user) hook groups verbatim — additive merge', () => {
const existing: Record<string, unknown> = {
version: 1,
hooks: {
sessionStart: [
{ command: 'node /existing/x.js', type: 'command', timeout: 10 },
],
},
};
const merged = jsonRegister(existing, [entry('session-start', 'session-start')], cursorRegisterSpec);
const arr = groupsAt(merged, 'sessionStart');
expect(arr).toHaveLength(2);
expect(arr[0].command).toBe('node /existing/x.js');
expect(arr[0]._hiveMindShim).toBeUndefined();
expect(arr[1]._hiveMindShim).toBe(HIVE_MIND_MARKER);
});
it('preserves unrelated top-level keys', () => {
const merged = jsonRegister(
{ version: 1, settingsUnrelated: { foo: 'bar' }, hooks: {} },
[entry('session-start', 'session-start')],
cursorRegisterSpec,
);
expect(merged['settingsUnrelated']).toEqual({ foo: 'bar' });
});
it('replaces our own marker-tagged group on re-install (idempotent dedup by command)', () => {
const e = entry('session-start', 'session-start', 5);
const merged1 = jsonRegister({}, [e], cursorRegisterSpec);
const merged2 = jsonRegister(merged1, [{ ...e, timeout: 11 }], cursorRegisterSpec);
const arr = groupsAt(merged2, 'sessionStart');
expect(arr).toHaveLength(1); // never duplicated
expect(arr[0].timeout).toBe(11);
});
});
describe('hasHiveEntries (cursor)', () => {
it('false on empty / hookless config', () => {
expect(hasHiveEntries(undefined, cursorRegisterSpec)).toBe(false);
expect(hasHiveEntries({}, cursorRegisterSpec)).toBe(false);
expect(hasHiveEntries({ version: 1, hooks: {} }, cursorRegisterSpec)).toBe(false);
});
it('true once a marker-tagged group is present', () => {
const merged = jsonRegister({}, [entry('stop', 'stop')], cursorRegisterSpec);
expect(hasHiveEntries(merged, cursorRegisterSpec)).toBe(true);
});
it('false for a config holding ONLY non-hive (user) groups', () => {
const userOnly: Record<string, unknown> = {
version: 1,
hooks: { stop: [{ command: 'node /user/own.js', type: 'command', timeout: 5 }] },
};
expect(hasHiveEntries(userOnly, cursorRegisterSpec)).toBe(false);
});
});
describe('jsonUnregister (cursor)', () => {
it('strips exactly our marker-tagged groups, preserves user groups', () => {
const userGroup = { command: 'node /user/own.js', type: 'command', timeout: 5 };
const withUser: Record<string, unknown> = { version: 1, hooks: { stop: [userGroup] } };
const merged = jsonRegister(withUser, [entry('stop', 'stop')], cursorRegisterSpec);
expect(groupsAt(merged, 'stop')).toHaveLength(2);
const stripped = jsonUnregister(merged, cursorRegisterSpec);
const arr = groupsAt(stripped, 'stop');
expect(arr).toHaveLength(1);
expect(arr[0].command).toBe('node /user/own.js');
expect(hasHiveEntries(stripped, cursorRegisterSpec)).toBe(false);
});
});

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, afterEach } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { install } from '../src/install.js';
import { uninstall } from '../src/uninstall.js';
interface TestEnv {
home: string;
hooksDir: string;
configPath: string;
pointerPath: string;
}
async function bootstrap(initial: Record<string, unknown> | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmcur-uninstall-'));
const cursorDir = join(home, '.cursor');
await mkdir(cursorDir, { recursive: true });
const configPath = join(cursorDir, 'hooks.json');
if (initial !== undefined) {
await writeFile(configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
const hooksDir = resolve(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
return { home, hooksDir, configPath, pointerPath: join(cursorDir, 'hive-mind-install.json') };
}
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf-8').digest('hex');
}
describe('uninstall (cursor)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when no pointer file exists', async () => {
env = await bootstrap({ version: 1, hooks: {} });
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/pointer/);
});
it('throws when the pointer is malformed', async () => {
env = await bootstrap({ version: 1, hooks: {} });
await writeFile(env.pointerPath, '{}', 'utf-8');
await expect(uninstall({ home: env.home, hooksDir: env.hooksDir }))
.rejects.toThrow(/malformed/);
});
// ── created_by_us=false: byte-identical restore (§7.3 invariant 2) ─────
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
const initial = {
version: 1,
hooks: {
sessionStart: [
{ command: 'node /existing/ctx.js', type: 'command', timeout: 10 },
],
preCompact: [
{ command: 'node /existing/pre-compact.js', type: 'command', timeout: 10 },
],
},
};
env = await bootstrap(initial);
const preInstall = await readFile(env.configPath, 'utf-8');
const preHash = sha256(preInstall);
await install({ home: env.home, hooksDir: env.hooksDir });
const afterInstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.createdRemoved).toBe(false);
const afterUninstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterUninstall)).toBe(preHash);
expect(afterUninstall).toBe(preInstall);
});
it('removes backup + pointer by default after a restore', async () => {
env = await bootstrap({ version: 1, hooks: {} });
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(existsSync(result.backupPath as string)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.backupRemoved).toBe(true);
expect(existsSync(result.backupPath as string)).toBe(false);
expect(existsSync(result.pointerPath)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
env = await bootstrap({ version: 1, hooks: {} });
const result = await install({ home: env.home, hooksDir: env.hooksDir });
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir, cleanupBackup: false });
expect(u.backupRemoved).toBe(false);
expect(existsSync(result.backupPath as string)).toBe(true);
});
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
it('deletes the hooks.json we created and leaves NO orphan (absent → install → uninstall)', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, hooksDir: env.hooksDir });
expect(result.createdByUs).toBe(true);
expect(existsSync(env.configPath)).toBe(true);
const u = await uninstall({ home: env.home, hooksDir: env.hooksDir });
expect(u.createdRemoved).toBe(true);
expect(u.restoredFrom).toBeNull();
// No orphaned config, no leftover backup, no leftover pointer.
expect(existsSync(env.configPath)).toBe(false);
expect(existsSync(env.pointerPath)).toBe(false);
});
});

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { install } from '../src/install.js';
import { verify } from '../src/verify.js';
interface TestEnv {
home: string;
hooksDir: string;
cursorDir: string;
}
async function bootstrap(
initial: Record<string, unknown> | undefined,
withHookFiles: boolean,
): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmcur-verify-'));
const cursorDir = join(home, '.cursor');
await mkdir(cursorDir, { recursive: true });
if (initial !== undefined) {
await writeFile(join(cursorDir, 'hooks.json'), JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
const hooksDir = join(home, 'fake-dist', 'hooks');
await mkdir(hooksDir, { recursive: true });
if (withHookFiles) {
for (const b of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
await writeFile(join(hooksDir, `${b}.js`), '/* mock hook */', 'utf-8');
}
}
return { home, hooksDir, cursorDir };
}
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => emitter.emit('exit', opts.exitCode));
return child;
}) as unknown as typeof import('node:child_process').spawn;
}
describe('verify (cursor)', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
it('reports failure when hooks.json is missing', async () => {
const env = await bootstrap(undefined, true);
envs.push(env);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks[0].name).toBe('hooks.json exists');
expect(result.checks[0].ok).toBe(false);
});
it('reports failure when hooks are not yet installed', async () => {
const env = await bootstrap({ version: 1, hooks: {} }, true);
envs.push(env);
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind entries'))).toBe(true);
});
it('passes after install with hook files on disk and CLI reachable', async () => {
const env = await bootstrap({ version: 1, hooks: {} }, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(true);
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
expect(cliCheck?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'hooks.json contains hive-mind entries')?.ok).toBe(true);
expect(result.checks.filter((c) => c.name.includes('readable on disk')).every((c) => c.ok)).toBe(true);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap({ version: 1, hooks: {} }, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
});
expect(result.ok).toBe(false);
const cliCheck = result.checks.find((c) => c.name === 'hive-mind-cli reachable');
expect(cliCheck?.ok).toBe(false);
});
it('flags missing hook script files even when the settings entry is present', async () => {
const env = await bootstrap({ version: 1, hooks: {} }, false); // no hook .js files
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
expect(fileCheck?.ok).toBe(false);
});
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
const env = await bootstrap({ version: 1, hooks: {} }, true);
envs.push(env);
const cliPath = '/abs/from/pointer.js';
await install({ home: env.home, hooksDir: env.hooksDir, cliPath });
const records: Array<{ command: string; args: readonly string[] }> = [];
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
records.push({ command: cmd, args });
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
}) as typeof import('node:child_process').spawn;
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: recordingSpawn,
});
expect(result.ok).toBe(true);
const probeRecord = records[records.length - 1];
expect(probeRecord.command).toBe(process.execPath);
expect(probeRecord.args[0]).toBe(cliPath);
expect(probeRecord.args[1]).toBe('--help');
});
// ── cursor-specific surfacing ─────────────────────────────────────────
it('always surfaces the Cursor restart advisory as an informational check', async () => {
const env = await bootstrap({ version: 1, hooks: {} }, true);
envs.push(env);
await install({ home: env.home, hooksDir: env.hooksDir });
const result = await verify({
home: env.home,
hooksDir: env.hooksDir,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const restart = result.checks.find((c) => c.name.toLowerCase().includes('restart'));
expect(restart).toBeDefined();
expect(restart?.ok).toBe(true);
expect(restart?.detail?.toLowerCase()).toContain('restart cursor');
});
});