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,19 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 Egzakta Group d.o.o. · waggle-os.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Full license text: https://www.apache.org/licenses/LICENSE-2.0.txt

View File

@@ -0,0 +1,66 @@
# @waggle/hive-mind-hooks-cursor
Silent-capture shim that wires **Cursor** lifecycle hooks into
[hive-mind](https://github.com/marolinik/hive-mind) frames. Every Cursor
session deterministically captures sessionStart / beforeSubmitPrompt / stop /
preCompact events into your personal memory via `hive-mind-cli` — the same
every-turn pattern proven by `@waggle/hive-mind-hooks-claude-code`, built on
the shared `@waggle/hive-mind-hooks-core` foundation.
Cursor's hook config is a JSON installer with **create-if-missing** semantics:
`~/.cursor/hooks.json` is optional, and is a **separate file** from Cursor's
`settings.json` (editor preferences) — this shim never touches your editor
prefs.
> **Not hook parity with claude-code.** Cursor renames the four lifecycle
> events and **degrades two** of them. See the Capture fidelity table below.
## Install
```bash
npx @waggle/hive-mind-hooks-cursor install
# Windows / production: pin the CLI path
npx @waggle/hive-mind-hooks-cursor install --cli-path "C:\\path\\to\\hive-mind-cli\\dist\\index.js"
```
The installer additively merges four hook groups into `~/.cursor/hooks.json`
(creating the file with a `{ "version": 1, "hooks": {} }` skeleton if absent),
preserving any existing hooks you have, and writes a pointer + byte-identical
backup so uninstall is exact.
> **Restart required.** Editing `hooks.json` needs a Cursor restart for the
> hooks to take effect. After installing, restart Cursor.
```bash
npx @waggle/hive-mind-hooks-cursor verify # smoke-check
npx @waggle/hive-mind-hooks-cursor uninstall # byte-identical restore (or remove if we created it)
```
## Capture fidelity
| Lifecycle | Cursor event | Status | Notes |
|---|---|---|---|
| SessionStart (recall + inject) | `sessionStart` | full | injects recalled frames via `{ additional_context }` (cursor's inject shape) |
| UserPromptSubmit (save temporary) | `beforeSubmitPrompt` | **degraded — save-only** | persists the prompt as a temporary frame; **cannot inject** context (stdout is only `{ continue, user_message }`) |
| Stop (summarize + save) | `stop` | full (transcript-sourced) | the completed turn is read from the base `transcript_path` field (not inline); the reader fails open when transcripts are disabled |
| PreCompact (compact memory) | `preCompact` | **degraded — observational** | cannot block/reorder, so `cleanup_frames` runs **best-effort / fire-and-forget**, not guaranteed before host truncation |
**Degraded events disclosed:**
- `beforeSubmitPrompt` is **save-only** — it persists your prompt but cannot
inject recalled memory back into the prompt (hive-mind's UserPromptSubmit
only persists, so this is fully compatible).
- `preCompact` is **observational only**`compact_memory` runs best-effort
and is not guaranteed to run before Cursor truncates context.
- `stop` reads the completed turn from `transcript_path` (off disk). When
Cursor transcripts are disabled the field is absent; the Stop hook then
saves an empty-response frame and exits 0 (fail-open).
## How it works
Each hook is a short-lived Node subprocess. Cursor pipes the event JSON to the
hook's stdin; the hook shells to `hive-mind-cli` to recall or save frames, then
exits 0. **Fail-open:** if `hive-mind-cli` is unreachable or the payload is
malformed, the hook logs to stderr and exits 0 — Cursor is never blocked.
License: Apache-2.0.

View File

@@ -0,0 +1,65 @@
{
"name": "@waggle/hive-mind-hooks-cursor",
"version": "0.1.0",
"description": "Cursor silent capture shim for hive-mind. Adds sessionStart / beforeSubmitPrompt / stop / preCompact hooks that route conversation episodes into hive-mind frames via @waggle/hive-mind-shim-core. Reversible, create-if-missing install — additive merge into ~/.cursor/hooks.json with byte-identical uninstall. Degraded events: beforeSubmitPrompt is save-only (no inject), preCompact is observational, Stop turn read via transcript_path. Built on @waggle/hive-mind-hooks-core.",
"license": "Apache-2.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"cursor-hooks": "dist/bin/cursor-hooks.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./hooks/session-start": "./dist/hooks/session-start.js",
"./hooks/user-prompt-submit": "./dist/hooks/user-prompt-submit.js",
"./hooks/stop": "./dist/hooks/stop.js",
"./hooks/pre-compact": "./dist/hooks/pre-compact.js"
},
"scripts": {
"build": "tsc --build",
"build:clean": "tsc --build --clean",
"typecheck": "tsc --build && tsc --noEmit -p tsconfig.test.json",
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/hive-mind-hooks-cursor/tests",
"test:watch": "vitest"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@waggle/hive-mind-hooks-core": "*",
"@waggle/hive-mind-shim-core": "*"
},
"peerDependencies": {
"@waggle/hive-mind-cli": "*"
},
"peerDependenciesMeta": {
"@waggle/hive-mind-cli": {
"optional": true
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/marolinik/waggle-os.git",
"directory": "packages/hive-mind-hooks-cursor"
},
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-cursor#readme",
"bugs": {
"url": "https://github.com/marolinik/waggle-os/issues"
},
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
"keywords": [
"cursor",
"hive-mind",
"memory",
"ai",
"hook",
"silent-capture"
],
"types": "dist/index.d.ts"
}

View File

@@ -0,0 +1,166 @@
/**
* Cursor EventAdapter + JsonRegisterSpec.
*
* Cursor's hook surface renames the four lifecycle events and degrades two
* of them relative to claude-code (spec §5.3):
* - SessionStart → `sessionStart`. The inject response is shaped
* `{ additional_context: text }` (a rename of CC's
* `hookSpecificOutput.additionalContext`), so `formatInject` overrides
* the default.
* - UserPromptSubmit → `beforeSubmitPrompt`. SAVE-ONLY — it cannot inject
* context (stdout is only `{ continue, user_message }`). The shared
* UserPromptSubmit body never injects, so no adapter change is needed;
* the README documents the degradation.
* - Stop → `stop`. The completed turn is delivered via the base field
* `transcript_path` (NOT inline), so `extractResponse` is ASYNC and
* reads the file off disk. It is defensive: tolerates a missing/empty
* path (transcripts disabled), does NOT assume JSONL, and fails open
* (returns undefined rather than throwing).
* - PreCompact → `preCompact`. Observational only — it cannot block or
* reorder, so `compact_memory` runs best-effort / fire-and-forget.
*
* The register shape is FLAT (`{ command, type:'command', timeout }`) under
* `hooks.<event>` arrays — unlike codex's `{ matcher, hooks:[...] }`
* wrapper. The marker is carried for byte-identical reversible uninstall.
*/
import { readFile } from 'node:fs/promises';
import {
pickStringField,
HIVE_MIND_MARKER_BASE,
type EventAdapter,
type ExtractContext,
type JsonRegisterSpec,
type Lifecycle,
} from '@waggle/hive-mind-hooks-core';
/** Per-tool marker stamped on every group we add — used by uninstall/verify. */
export const HIVE_MIND_MARKER = `${HIVE_MIND_MARKER_BASE}/cursor-hooks`;
/** Canonical lifecycle → Cursor native event key (field renames). */
export const CURSOR_EVENT_NAME: Record<Lifecycle, string | undefined> = {
'session-start': 'sessionStart',
'user-prompt-submit': 'beforeSubmitPrompt',
'stop': 'stop',
'pre-compact': 'preCompact',
};
function asObject(payload: unknown): Record<string, unknown> | undefined {
return payload && typeof payload === 'object'
? (payload as Record<string, unknown>)
: undefined;
}
/**
* Defensive transcript reader for Cursor's Stop event. Cursor writes the
* completed turn to a file referenced by the base `transcript_path` field
* rather than inlining it. We:
* - tolerate a missing/empty path (transcripts disabled) → undefined;
* - prefer the caller-supplied `ctx.readFile`, else use node fs;
* - do NOT assume a JSONL structure — the raw file text IS the response
* fallback (the shared summarizer is text-tolerant);
* - fail open: any read error returns undefined, never throws.
*/
async function readTranscript(
transcriptPath: string,
ctx: ExtractContext,
): Promise<string | undefined> {
try {
const text = ctx.readFile
? await ctx.readFile(transcriptPath)
: await readFile(transcriptPath, 'utf-8');
const trimmed = text.trim();
return trimmed.length > 0 ? trimmed : undefined;
} catch {
return undefined;
}
}
/** The Cursor EventAdapter consumed by the shared lifecycle handler bodies. */
export const cursorAdapter: EventAdapter = {
source: 'cursor',
eventName: CURSOR_EVENT_NAME,
extractCwd(payload): string | undefined {
return pickStringField(payload, 'cwd', 'workspace_root', 'workspaceRoot');
},
extractSessionId(payload): string | undefined {
return pickStringField(payload, 'conversation_id', 'conversationId', 'session_id', 'sessionId');
},
extractPrompt(payload): string | undefined {
return pickStringField(payload, 'prompt', 'user_message', 'text');
},
async extractResponse(payload, ctx): Promise<string | undefined> {
// Cursor delivers the completed turn via the base `transcript_path`
// field (read off disk), not inline. Fall back to any inline keys for
// forward-compat, then to the transcript file.
const inline = pickStringField(payload, 'response', 'assistant_message', 'transcript');
if (inline) return inline;
const transcriptPath = pickStringField(payload, 'transcript_path', 'transcriptPath');
if (!transcriptPath) return undefined;
return readTranscript(transcriptPath, ctx);
},
extractParent(payload): string | undefined {
return pickStringField(payload, 'parent_frame_id', 'prompt_frame_id');
},
/**
* Cursor's `sessionStart` inject response is `{ additional_context, env }`.
* We supply only `additional_context` (a rename of CC's
* `hookSpecificOutput.additionalContext`).
*/
formatInject(additionalContext: string): unknown {
return { additional_context: additionalContext };
},
};
// ── JsonRegisterSpec (cursor FLAT `{ command, type, timeout }` group) ────
interface CursorGroup {
command: string;
type: 'command';
timeout: number;
_hiveMindShim?: string;
}
/**
* The Cursor register spec for `jsonRegister`/`jsonUnregister`. Groups live
* under `hooks.<event>` arrays as FLAT command objects (no `{matcher,hooks}`
* wrapper). `ensureSkeleton` seeds `version: 1` on a fresh config.
*/
export const cursorRegisterSpec: JsonRegisterSpec = {
hooksKey: 'hooks',
eventName: CURSOR_EVENT_NAME,
buildGroup(_lifecycle: Lifecycle, command: string, timeout: number): Record<string, unknown> {
const group: CursorGroup = {
command,
type: 'command',
timeout,
_hiveMindShim: HIVE_MIND_MARKER,
};
return group as unknown as Record<string, unknown>;
},
isHiveGroup(group: unknown): boolean {
const g = asObject(group);
return !!g && g['_hiveMindShim'] === HIVE_MIND_MARKER;
},
groupCommand(group: unknown): string | undefined {
const g = asObject(group);
const cmd = g?.['command'];
return typeof cmd === 'string' ? cmd : undefined;
},
ensureSkeleton(root: Record<string, unknown>): Record<string, unknown> {
// Cursor's hooks.json skeleton is `{ version: 1, hooks: {} }`. Seed the
// version only when absent; never clobber a user-set version.
if (root['version'] === undefined) return { version: 1, ...root };
return root;
},
};

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* `cursor-hooks` — CLI entry for the @waggle/hive-mind-hooks-cursor shim.
*
* cursor-hooks install Patch ~/.cursor/hooks.json (additive, create-if-missing).
* cursor-hooks uninstall Restore the byte-identical pre-install state (or remove
* the hooks.json we created).
* cursor-hooks verify Smoke-check the install + hive-mind-cli reachability.
*/
import { install, type InstallResult } from '../install.js';
import { uninstall, type UninstallResult } from '../uninstall.js';
import { verify, type VerifyResult } from '../verify.js';
type ParsedArgs = {
command: 'install' | 'uninstall' | 'verify' | 'help';
flags: Record<string, string | boolean>;
};
function parseArgs(argv: readonly string[]): ParsedArgs {
const [first, ...rest] = argv;
const valid = ['install', 'uninstall', 'verify'] as const;
const command = first === '-h' || first === '--help' || first === undefined
? 'help'
: valid.includes(first as typeof valid[number]) ? first as typeof valid[number] : 'help';
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
const next = rest[i + 1];
if (next && !next.startsWith('--')) {
flags[arg.slice(2)] = next;
i += 1;
} else {
flags[arg.slice(2)] = true;
}
}
}
return { command: command as ParsedArgs['command'], flags };
}
function printHelp(): void {
process.stdout.write([
'Usage: cursor-hooks <command> [options]',
'',
'Commands:',
' install Patch ~/.cursor/hooks.json (additive, create-if-missing, with backup).',
' uninstall Restore the byte-identical pre-install hooks.json (or remove it if created).',
' verify Smoke-check the install + hive-mind-cli reachability.',
'',
'Options:',
' --help, -h Show this help.',
' --hooks-dir <PATH> Override compiled hooks directory (testing).',
' --hook-timeout <S> Override per-hook timeout in seconds (default 5).',
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
' compiled JS entry. Required on Windows (npm bin',
' is a .cmd shim) and recommended for production',
' installs. Threaded into every hook command.',
'',
'Repo: https://github.com/marolinik/waggle-os',
'',
].join('\n'));
}
function printInstallSummary(result: InstallResult): void {
const lines: string[] = [
'hive-mind/cursor-hooks: install',
` - hooks.json: ${result.paths.configPath}`,
` - backup: ${result.backupPath ?? '(none — hooks.json created by us)'}`,
` - pointer: ${result.pointerPath}`,
` - added hooks: ${result.installedHooks.join(', ')}`,
` - cli path: ${result.cliPath ?? '(default — hive-mind-cli on PATH)'}`,
'',
'Restart required:',
' Restart Cursor for hive-mind hooks to take effect.',
'',
'Note: cursor degrades two events — beforeSubmitPrompt saves only (no',
'inject) and preCompact is observational (best-effort). See the README',
'Capture fidelity table.',
'',
'Done. New Cursor sessions will silently capture to hive-mind.',
'Run "cursor-hooks verify" to inspect, "cursor-hooks uninstall" to revert.',
'',
];
process.stdout.write(lines.join('\n'));
}
function printUninstallSummary(result: UninstallResult): void {
const lines: string[] = [
'hive-mind/cursor-hooks: uninstall',
` - hooks.json: ${result.paths.configPath}`,
` - restored from: ${result.restoredFrom ?? '(none — removed file we created)'}`,
` - created removed: ${result.createdRemoved ? 'yes' : 'no'}`,
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
'',
'Restart Cursor for the change to take effect.',
'hooks.json is byte-identical to pre-install state (or removed if we created it).',
'',
];
process.stdout.write(lines.join('\n'));
}
function printVerifySummary(result: VerifyResult): void {
const lines: string[] = ['hive-mind/cursor-hooks: verify'];
for (const c of result.checks) {
const tag = c.ok ? 'PASS' : 'FAIL';
const detail = c.detail ? `${c.detail}` : '';
lines.push(` [${tag}] ${c.name}${detail}`);
}
lines.push('');
lines.push(result.ok ? 'All checks passed.' : 'One or more checks failed.');
lines.push('');
process.stdout.write(lines.join('\n'));
}
async function main(): Promise<void> {
const { command, flags } = parseArgs(process.argv.slice(2));
if (command === 'help') {
printHelp();
return;
}
const hooksDir = typeof flags['hooks-dir'] === 'string' ? flags['hooks-dir'] : undefined;
const hookTimeoutRaw = flags['hook-timeout'];
const hookTimeoutSeconds = typeof hookTimeoutRaw === 'string'
? Number.parseInt(hookTimeoutRaw, 10) || undefined
: undefined;
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
const baseOpts = {
moduleUrl: import.meta.url,
...(hooksDir ? { hooksDir } : {}),
};
try {
if (command === 'install') {
const installOpts = {
...baseOpts,
...(hookTimeoutSeconds !== undefined ? { hookTimeoutSeconds } : {}),
...(cliPath !== undefined ? { cliPath } : {}),
};
const result = await install(installOpts);
printInstallSummary(result);
return;
}
if (command === 'uninstall') {
const result = await uninstall(baseOpts);
printUninstallSummary(result);
return;
}
if (command === 'verify') {
const result = await verify(baseOpts);
printVerifySummary(result);
if (!result.ok) process.exit(1);
return;
}
} catch (err) {
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,38 @@
/**
* Cursor preCompact hook — fired around Cursor's context truncation.
* Triggers `cleanup_frames` so superseded P/B frames merge before the
* native compaction step. Thin entrypoint over the shared handler body.
*
* DEGRADED (spec §5.3): cursor's `preCompact` is OBSERVATIONAL only — it
* cannot block or reorder, so "run compact_memory BEFORE the host
* truncates" is best-effort, not guaranteed-before. `cleanup_frames` runs
* fire-and-forget; if hive-mind-cli is unreachable the hook exits 0.
*/
import {
makePreCompactHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { cursorAdapter } from '../adapter.js';
export async function runPreCompact(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makePreCompactHandler(cursorAdapter), {
name: 'pre-compact',
loggerPrefix: 'cursor-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runPreCompact();
}

View File

@@ -0,0 +1,39 @@
/**
* Cursor sessionStart hook — recalls the top-N most relevant frames from
* personal memory and injects them as additional context for the new
* Cursor session. Thin entrypoint over the shared handler body. The cursor
* adapter's `formatInject` shapes the inject response as
* `{ additional_context: text }` (cursor's `sessionStart` convention).
*
* If hive-mind-cli is unreachable, the hook logs and exits 0 with no
* output (fail-open) — the session starts as it would have without the
* shim.
*/
import {
makeSessionStartHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { cursorAdapter } from '../adapter.js';
export async function runSessionStart(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeSessionStartHandler(cursorAdapter), {
name: 'session-start',
loggerPrefix: 'cursor-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runSessionStart();
}

View File

@@ -0,0 +1,40 @@
/**
* Cursor stop hook — fired when an assistant turn completes. Summarizes the
* turn deterministically (no LLM call) and writes it as an `important`
* frame parented to the originating prompt frame when known. Thin
* entrypoint over the shared handler body.
*
* DEGRADED (spec §5.3): cursor delivers the completed turn via the base
* field `transcript_path` (NOT inline). The cursor adapter's
* `extractResponse` is async, reads that file defensively (tolerates a
* missing path when transcripts are disabled, does not assume JSONL), and
* fails open — returning undefined rather than throwing.
*/
import {
makeStopHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { cursorAdapter } from '../adapter.js';
export async function runStop(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeStopHandler(cursorAdapter), {
name: 'stop',
loggerPrefix: 'cursor-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runStop();
}

View File

@@ -0,0 +1,39 @@
/**
* Cursor beforeSubmitPrompt hook — captures the user prompt as a temporary
* frame scoped to the current Cursor session. Thin entrypoint over the
* shared handler body.
*
* DEGRADED (spec §5.3): cursor's `beforeSubmitPrompt` is SAVE-ONLY — its
* stdout is only `{ continue, user_message }`, so it cannot inject recalled
* context. hive-mind's UserPromptSubmit only persists, so this is fully
* compatible; the hook emits no stdout and is a pure side-effect on the
* .mind file.
*/
import {
makeUserPromptSubmitHandler,
runHook,
type HookRunOptions,
} from '@waggle/hive-mind-hooks-core';
import { cursorAdapter } from '../adapter.js';
export async function runUserPromptSubmit(opts: Partial<HookRunOptions> = {}): Promise<void> {
return runHook(makeUserPromptSubmitHandler(cursorAdapter), {
name: 'user-prompt-submit',
loggerPrefix: 'cursor-hooks',
...opts,
});
}
const isMain = (() => {
try {
if (typeof process.argv[1] !== 'string') return false;
const url = new URL(`file://${process.argv[1].replace(/\\/g, '/')}`);
return url.href === import.meta.url;
} catch {
return false;
}
})();
if (isMain) {
void runUserPromptSubmit();
}

View File

@@ -0,0 +1,50 @@
/**
* @waggle/hive-mind-hooks-cursor — barrel export.
*
* Cursor silent-capture shim for hive-mind. A JSON installer built on
* @waggle/hive-mind-hooks-core, with create-if-missing semantics (cursor's
* `~/.cursor/hooks.json` is optional, and is a SEPARATE file from Cursor's
* `settings.json`). Field-renamed events (sessionStart / beforeSubmitPrompt
* / stop / preCompact) with two degraded events (beforeSubmitPrompt is
* save-only; preCompact is observational; Stop reads the turn from
* `transcript_path`). Programmatic install / uninstall / verify lifecycle;
* most users invoke the `cursor-hooks` bin.
*/
export type {
InstallOptions,
InstallResult,
} from './install.js';
export { install } from './install.js';
export type {
UninstallOptions,
UninstallResult,
} from './uninstall.js';
export { uninstall } from './uninstall.js';
export type {
VerifyOptions,
VerifyResult,
VerifyCheck,
} from './verify.js';
export { verify } from './verify.js';
export type {
CursorPaths,
ResolvePathsOptions,
HookBasename,
} from './paths.js';
export {
resolvePaths,
allHookBasenames,
backupPathFor,
hookCommandFor,
} from './paths.js';
export {
cursorAdapter,
cursorRegisterSpec,
CURSOR_EVENT_NAME,
HIVE_MIND_MARKER,
} from './adapter.js';

View File

@@ -0,0 +1,164 @@
/**
* Programmatic install entry point for the Cursor hive-mind hooks.
*
* Steps:
* 1. Read existing `~/.cursor/hooks.json` IF it exists (create-if-missing
* — cursor's hooks.json is OPTIONAL on a fresh install). It is a
* SEPARATE file from Cursor's `settings.json`; we never touch editor
* preferences.
* 2. If it pre-existed, write a byte-identical backup; if absent, skip
* the backup and record `created_by_us=true`.
* 3. Additively merge the four hive-mind hook groups (flat
* `{ command, type, timeout }` shape) into `hooks.<event>` arrays via
* `jsonRegister`. A fresh config is seeded `{ version: 1, hooks: {} }`
* by the spec's `ensureSkeleton`. Existing entries preserved verbatim.
* 4. Write the merged JSON back over `hooks.json`.
* 5. Drop a pointer file at `~/.cursor/hive-mind-install.json` so a future
* `uninstall` knows whether to restore the backup or delete the file
* we created.
*
* Round-trip guarantee (pre-existed case): the pre-install hooks.json
* content equals the byte-identical backup; `uninstall` restores it.
* Created case: `uninstall` deletes the file we created (no orphan).
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
backupByteIdentical,
hookCommandFor,
hookScriptPath,
jsonRegister,
normalizeCliPath,
writePointer,
type InstallPointer,
type JsonRegisterEntry,
type Lifecycle,
} from '@waggle/hive-mind-hooks-core';
import { resolvePaths, allHookBasenames, type CursorPaths, type ResolvePathsOptions } from './paths.js';
import { cursorRegisterSpec } from './adapter.js';
export interface InstallResult {
paths: CursorPaths;
/** The byte-identical backup written when hooks.json pre-existed, else null. */
backupPath: string | null;
pointerPath: string;
installedHooks: readonly string[];
/** True when hooks.json did NOT pre-exist and we created it. */
createdByUs: boolean;
/** The cli_path embedded in hook commands (undefined = default lookup at runtime). */
cliPath?: string;
}
export interface InstallOptions extends ResolvePathsOptions {
/** Per-hook timeout, seconds. Default 5. */
hookTimeoutSeconds?: number;
/** Override clock for deterministic tests. */
now?: () => Date;
/** Logger override. */
logger?: Logger;
/**
* Absolute path to the hive-mind-cli binary or its compiled JS entry.
* Required on Windows (npm bin shim is `.cmd` and can't be exec'd
* without a shell). Threaded into every hook command as
* `--cli-path "<path>"`.
*/
cliPath?: string;
}
const DEFAULT_HOOK_TIMEOUT_S = 5;
const POINTER_VERSION = '0.1.0';
/** Canonical lifecycle for each hook basename (basenames mirror lifecycle ids). */
const LIFECYCLE_BY_BASENAME: Record<string, Lifecycle> = {
'session-start': 'session-start',
'user-prompt-submit': 'user-prompt-submit',
'stop': 'stop',
'pre-compact': 'pre-compact',
};
async function ensureDir(p: string): Promise<void> {
if (!existsSync(p)) await mkdir(p, { recursive: true });
}
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
const log = opts.logger ?? createLogger({ name: 'cursor-hooks/install' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const now = opts.now ?? ((): Date => new Date());
log.info('install starting', { config: paths.configPath, hooksDir: paths.hooksDir });
// Read the existing config if present; create-if-missing otherwise.
let existingConfig: Record<string, unknown> | undefined;
const preExisted = existsSync(paths.configPath);
if (preExisted) {
const originalContent = await readFile(paths.configPath, 'utf-8');
try {
const parsed: unknown = JSON.parse(originalContent);
existingConfig = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch (err) {
throw new Error(
`failed to parse existing ${paths.configPath} as JSON: ` +
(err instanceof Error ? err.message : String(err)),
);
}
}
await ensureDir(dirname(paths.pointerPath));
// Byte-identical backup of the original config (no-op when absent).
const { backupPath, preExisted: backedUp } = await backupByteIdentical(
paths.configPath,
now().toISOString(),
);
if (backedUp) log.info('hooks.json backed up', { backupPath });
const cliPath = normalizeCliPath(opts.cliPath);
const timeout = opts.hookTimeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_S;
const basenames = allHookBasenames();
const entries: JsonRegisterEntry[] = basenames.map((basename) => ({
lifecycle: LIFECYCLE_BY_BASENAME[basename],
command: hookCommandFor(hookScriptPath(paths.hooksDir, basename), cliPath),
timeout,
}));
const merged = jsonRegister(existingConfig, entries, cursorRegisterSpec);
const mergedJson = JSON.stringify(merged, null, 2) + '\n';
await writeFile(paths.configPath, mergedJson, 'utf-8');
const createdByUs = !preExisted;
const pointer: InstallPointer = {
version: POINTER_VERSION,
installed_at: now().toISOString(),
config_path: paths.configPath,
settings_backup: backupPath,
created_by_us: createdByUs,
hooks_dir: paths.hooksDir,
installed_hooks: basenames,
cli_path: cliPath ?? null,
};
await writePointer(paths.pointerPath, pointer);
log.info('install complete', {
added: entries.length,
createdByUs,
cliPath: cliPath ?? '(PATH lookup)',
});
const result: InstallResult = {
paths,
backupPath,
pointerPath: paths.pointerPath,
installedHooks: basenames,
createdByUs,
};
if (cliPath !== undefined) result.cliPath = cliPath;
return result;
}

View File

@@ -0,0 +1,73 @@
/**
* Filesystem path helpers for the Cursor hive-mind hook install lifecycle.
*
* Mirrors the Codex `paths.ts` shape, but targets Cursor's standalone
* `~/.cursor/hooks.json` (JSON) — a SEPARATE file from Cursor's
* `settings.json` (editor prefs); we never touch editor preferences. The
* Windows-safe backup path + `--cli-path` quoting + hooks-dir resolution
* are reused verbatim from `@waggle/hive-mind-hooks-core` so cursor reads
* like the reference.
*/
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import {
backupPathFor,
hookCommandFor,
hooksDirFromModuleUrl,
} from '@waggle/hive-mind-hooks-core';
export interface CursorPaths {
/** Cursor config root (`~/.cursor/`). */
cursorDir: string;
/** `~/.cursor/hooks.json` — the standalone hooks config (NOT settings.json). */
configPath: string;
/** `~/.cursor/hive-mind-install.json` — pointer to the active backup. */
pointerPath: string;
/** Directory where compiled hook scripts live (`dist/hooks/`). */
hooksDir: string;
}
export interface ResolvePathsOptions {
/** Override $HOME for tests. */
home?: string;
/** Override the URL used to locate dist/hooks (defaults to import.meta.url at runtime). */
moduleUrl?: string;
/** Override hooks directory directly (wins over moduleUrl). */
hooksDir?: string;
}
const HOOK_BASENAMES = [
'session-start',
'user-prompt-submit',
'stop',
'pre-compact',
] as const;
export type HookBasename = typeof HOOK_BASENAMES[number];
export function allHookBasenames(): readonly HookBasename[] {
return HOOK_BASENAMES;
}
export function resolvePaths(opts: ResolvePathsOptions = {}): CursorPaths {
const home = opts.home ?? homedir();
const cursorDir = join(home, '.cursor');
const configPath = join(cursorDir, 'hooks.json');
const pointerPath = join(cursorDir, 'hive-mind-install.json');
let hooksDir: string;
if (opts.hooksDir) {
hooksDir = resolve(opts.hooksDir);
} else if (opts.moduleUrl) {
hooksDir = hooksDirFromModuleUrl(opts.moduleUrl);
} else {
// Fallback for ad-hoc test use — install.ts always passes moduleUrl.
hooksDir = resolve(process.cwd(), 'dist', 'hooks');
}
return { cursorDir, configPath, pointerPath, hooksDir };
}
/** Re-export the shared Windows-safe helpers so cursor modules read like CC. */
export { backupPathFor, hookCommandFor };

View File

@@ -0,0 +1,76 @@
/**
* Programmatic uninstall entry point for the Cursor hive-mind hooks.
*
* Round-trip guarantee:
* - `created_by_us=false` (hooks.json pre-existed): restore the
* byte-identical backup the installer wrote; refuse to delete the
* backup unless the in-place readback matches.
* - `created_by_us=true` (we created hooks.json): delete the file we
* created — never orphan it, never leave a backup behind.
*
* Both branches are handled by the shared `restoreFromBackup` primitive.
*/
import { unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import {
readPointer,
restoreFromBackup,
} from '@waggle/hive-mind-hooks-core';
import { resolvePaths, type CursorPaths, type ResolvePathsOptions } from './paths.js';
export interface UninstallResult {
paths: CursorPaths;
/** Backup restored from, or null when we deleted a file we created. */
restoredFrom: string | null;
/** True when created_by_us=true and we removed the config we created. */
createdRemoved: boolean;
pointerRemoved: boolean;
backupRemoved: boolean;
}
export interface UninstallOptions extends ResolvePathsOptions {
logger?: Logger;
/** If false, leaves the backup file in place after restore. Default true. */
cleanupBackup?: boolean;
}
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
const log = opts.logger ?? createLogger({ name: 'cursor-hooks/uninstall' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const cleanup = opts.cleanupBackup ?? true;
if (!existsSync(paths.pointerPath)) {
throw new Error(
`no install pointer found at ${paths.pointerPath}. ` +
`Was @waggle/hive-mind-hooks-cursor ever installed for this user?`,
);
}
const pointer = await readPointer(paths.pointerPath);
const restore = await restoreFromBackup({
configPath: paths.configPath,
pointer,
cleanupBackup: cleanup,
});
if (restore.createdRemoved) {
log.info('cursor hooks.json removed (created by us)', { config: paths.configPath });
} else {
log.info('cursor hooks.json restored byte-identical', { config: paths.configPath });
}
await unlink(paths.pointerPath);
return {
paths,
restoredFrom: restore.restoredFrom,
createdRemoved: restore.createdRemoved,
pointerRemoved: true,
backupRemoved: restore.backupRemoved,
};
}

View File

@@ -0,0 +1,162 @@
/**
* Smoke-check the Cursor install: hooks.json exists + parses + references
* live hook scripts, and hive-mind-cli answers a `--help` probe. Plus the
* cursor-specific advisory (spec §5.3 / §6.2): editing `hooks.json` likely
* needs a Cursor restart for the hooks to take effect.
*
* Probe priority for `cli_path`:
* 1. Explicit `opts.cliPath` (caller override)
* 2. `cli_path` recorded in `~/.cursor/hive-mind-install.json`
* 3. Bare `'hive-mind-cli'` on PATH
*/
import { readFile, access } from 'node:fs/promises';
import { constants, existsSync } from 'node:fs';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
import { hasHiveEntries } from '@waggle/hive-mind-hooks-core';
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
import { cursorRegisterSpec } from './adapter.js';
export interface VerifyCheck {
name: string;
ok: boolean;
detail?: string;
}
export interface VerifyResult {
ok: boolean;
checks: VerifyCheck[];
}
export interface VerifyOptions extends ResolvePathsOptions {
logger?: Logger;
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
cliPath?: string;
/** Test hook for spawn. */
spawnImpl?: typeof spawn;
}
async function fileReadable(p: string): Promise<boolean> {
try { await access(p, constants.R_OK); return true; } catch { return false; }
}
function isJsPath(p: string): boolean {
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
}
function probeCliVersion(
cliPath: string,
spawnImpl: typeof spawn,
timeoutMs: number,
): Promise<{ ok: boolean; output: string }> {
return new Promise((resolve) => {
let settled = false;
const command = isJsPath(cliPath) ? process.execPath : cliPath;
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch { /* gone */ }
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
}, timeoutMs);
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
});
child.on('exit', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
resolve({ ok: code === 0, output: code === 0 ? out : err });
});
});
}
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
const log = opts.logger ?? createLogger({ name: 'cursor-hooks/verify' });
const paths = resolvePaths({
...(opts.home !== undefined ? { home: opts.home } : {}),
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
});
const checks: VerifyCheck[] = [];
// 1. hooks.json exists and parses.
if (!existsSync(paths.configPath)) {
checks.push({ name: 'hooks.json exists', ok: false, detail: paths.configPath });
return { ok: false, checks };
}
checks.push({ name: 'hooks.json exists', ok: true, detail: paths.configPath });
let parsed: Record<string, unknown>;
try {
const raw: unknown = JSON.parse(await readFile(paths.configPath, 'utf-8'));
parsed = raw && typeof raw === 'object' && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
checks.push({ name: 'hooks.json parses as JSON', ok: true });
} catch (err) {
checks.push({
name: 'hooks.json parses as JSON',
ok: false,
detail: err instanceof Error ? err.message : String(err),
});
return { ok: false, checks };
}
// 2. hive-mind entries present.
checks.push({
name: 'hooks.json contains hive-mind entries',
ok: hasHiveEntries(parsed, cursorRegisterSpec),
});
// 3. each hive hook entry points at an existing dist file.
for (const basename of allHookBasenames()) {
const scriptPath = join(paths.hooksDir, `${basename}.js`);
const ok = await fileReadable(scriptPath);
checks.push({ name: `${basename}.js readable on disk`, ok, detail: scriptPath });
}
// 4. Cursor restart advisory (informational — editing hooks.json needs a
// Cursor restart for the hooks to take effect; reload semantics are
// unverified across 1.7.x).
checks.push({
name: 'Cursor restart (after install/uninstall)',
ok: true,
detail: 'Restart Cursor for hive-mind hooks to take effect.',
});
// 5. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
let cliPathFromPointer: string | undefined;
if (existsSync(paths.pointerPath)) {
try {
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
const pointerCliPath = pointerObj['cli_path'];
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
cliPathFromPointer = pointerCliPath;
}
} catch { /* pointer unreadable — fall through */ }
}
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
const spawnImpl = opts.spawnImpl ?? spawn;
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
checks.push({
name: 'hive-mind-cli reachable',
ok: probe.ok,
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
});
const ok = checks.every((c) => c.ok);
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
return { ok, checks };
}

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');
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"],
"references": [
{ "path": "../hive-mind-hooks-core" },
{ "path": "../hive-mind-shim-core" }
]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist/**", "node_modules/**"]
}