This commit is contained in:
19
packages/hive-mind-hooks-cursor/LICENSE
Normal file
19
packages/hive-mind-hooks-cursor/LICENSE
Normal 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
|
||||
66
packages/hive-mind-hooks-cursor/README.md
Normal file
66
packages/hive-mind-hooks-cursor/README.md
Normal 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.
|
||||
65
packages/hive-mind-hooks-cursor/package.json
Normal file
65
packages/hive-mind-hooks-cursor/package.json
Normal 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"
|
||||
}
|
||||
166
packages/hive-mind-hooks-cursor/src/adapter.ts
Normal file
166
packages/hive-mind-hooks-cursor/src/adapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
169
packages/hive-mind-hooks-cursor/src/bin/cursor-hooks.ts
Normal file
169
packages/hive-mind-hooks-cursor/src/bin/cursor-hooks.ts
Normal 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();
|
||||
38
packages/hive-mind-hooks-cursor/src/hooks/pre-compact.ts
Normal file
38
packages/hive-mind-hooks-cursor/src/hooks/pre-compact.ts
Normal 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();
|
||||
}
|
||||
39
packages/hive-mind-hooks-cursor/src/hooks/session-start.ts
Normal file
39
packages/hive-mind-hooks-cursor/src/hooks/session-start.ts
Normal 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();
|
||||
}
|
||||
40
packages/hive-mind-hooks-cursor/src/hooks/stop.ts
Normal file
40
packages/hive-mind-hooks-cursor/src/hooks/stop.ts
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
50
packages/hive-mind-hooks-cursor/src/index.ts
Normal file
50
packages/hive-mind-hooks-cursor/src/index.ts
Normal 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';
|
||||
164
packages/hive-mind-hooks-cursor/src/install.ts
Normal file
164
packages/hive-mind-hooks-cursor/src/install.ts
Normal 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;
|
||||
}
|
||||
73
packages/hive-mind-hooks-cursor/src/paths.ts
Normal file
73
packages/hive-mind-hooks-cursor/src/paths.ts
Normal 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 };
|
||||
76
packages/hive-mind-hooks-cursor/src/uninstall.ts
Normal file
76
packages/hive-mind-hooks-cursor/src/uninstall.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
162
packages/hive-mind-hooks-cursor/src/verify.ts
Normal file
162
packages/hive-mind-hooks-cursor/src/verify.ts
Normal 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 };
|
||||
}
|
||||
62
packages/hive-mind-hooks-cursor/tests/hooks/_test-helpers.ts
Normal file
62
packages/hive-mind-hooks-cursor/tests/hooks/_test-helpers.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
190
packages/hive-mind-hooks-cursor/tests/hooks/stop.test.ts
Normal file
190
packages/hive-mind-hooks-cursor/tests/hooks/stop.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
175
packages/hive-mind-hooks-cursor/tests/install.test.ts
Normal file
175
packages/hive-mind-hooks-cursor/tests/install.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
74
packages/hive-mind-hooks-cursor/tests/paths.test.ts
Normal file
74
packages/hive-mind-hooks-cursor/tests/paths.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
168
packages/hive-mind-hooks-cursor/tests/register.test.ts
Normal file
168
packages/hive-mind-hooks-cursor/tests/register.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
packages/hive-mind-hooks-cursor/tests/uninstall.test.ts
Normal file
118
packages/hive-mind-hooks-cursor/tests/uninstall.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
packages/hive-mind-hooks-cursor/tests/verify.test.ts
Normal file
167
packages/hive-mind-hooks-cursor/tests/verify.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
15
packages/hive-mind-hooks-cursor/tsconfig.json
Normal file
15
packages/hive-mind-hooks-cursor/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
12
packages/hive-mind-hooks-cursor/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-cursor/tsconfig.test.json
Normal 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/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user