/** * 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 { 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 { 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; try { const raw: unknown = JSON.parse(await readFile(paths.configPath, 'utf-8')); parsed = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; 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; 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 }; }