moving
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* `claude-code-hooks``claude-code-hooks` — CLI entry for the @waggle/hive-mind-hooks-claude-code shim (was @hive-mind/claude-code-hooks pre-monorepo migration).
|
||||
*
|
||||
* claude-code-hooks install Patch ~/.claude/settings.json (additive merge).
|
||||
* claude-code-hooks uninstall Restore the byte-identical pre-install state.
|
||||
* claude-code-hooks uninstall Restore the exact pre-install settings state.
|
||||
* claude-code-hooks verify Smoke-check the install + hive-mind-cli reachability.
|
||||
*/
|
||||
|
||||
@@ -49,13 +49,13 @@ function printHelp(): void {
|
||||
'',
|
||||
'Commands:',
|
||||
' install Patch ~/.claude/settings.json (additive, with backup).',
|
||||
' uninstall Restore the byte-identical pre-install settings.json.',
|
||||
' uninstall Restore pre-existing settings or remove installer-created settings.',
|
||||
' 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).',
|
||||
' --hook-timeout <S> Override all hook timeouts (defaults: SessionStart 15s, others 12s).',
|
||||
' --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',
|
||||
@@ -86,11 +86,13 @@ function printUninstallSummary(result: UninstallResult): void {
|
||||
const lines: string[] = [
|
||||
'hive-mind/claude-code-hooks: uninstall',
|
||||
` - settings: ${result.paths.settingsPath}`,
|
||||
` - restored from: ${result.restoredFrom}`,
|
||||
result.settingsRemoved
|
||||
? ' - settings state: removed (file was installer-created)'
|
||||
: ` - restored from: ${result.restoredFrom}`,
|
||||
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
|
||||
` - pointer removed: ${result.pointerRemoved ? 'yes' : 'no'}`,
|
||||
'',
|
||||
'Done. settings.json is byte-identical to pre-install state.',
|
||||
'Done. The pre-install settings state has been restored.',
|
||||
'',
|
||||
];
|
||||
process.stdout.write(lines.join('\n'));
|
||||
|
||||
@@ -60,6 +60,19 @@ export interface HookHandler<TPayload = unknown, TStdoutPayload = unknown> {
|
||||
}
|
||||
|
||||
const STDIN_READ_TIMEOUT_MS = 2000;
|
||||
const HOOK_CLI_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function buildHookBridgeOptions(
|
||||
logger: Logger,
|
||||
cliPath?: string,
|
||||
): CliBridgeOptions {
|
||||
return {
|
||||
logger,
|
||||
timeout_ms: HOOK_CLI_TIMEOUT_MS,
|
||||
max_retries: 0,
|
||||
...(cliPath !== undefined ? { cli_path: cliPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readStdinAsString(timeoutMs: number = STDIN_READ_TIMEOUT_MS): Promise<string> {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -106,8 +119,7 @@ export async function runHook<TPayload, TStdoutPayload>(
|
||||
const reader = opts.readStdin ?? readStdinAsString;
|
||||
const argv = opts.argv ?? process.argv.slice(2);
|
||||
const argvFlags = parseHookArgs(argv);
|
||||
const bridgeOpts: CliBridgeOptions = { logger };
|
||||
if (argvFlags.cliPath !== undefined) bridgeOpts.cli_path = argvFlags.cliPath;
|
||||
const bridgeOpts = buildHookBridgeOptions(logger, argvFlags.cliPath);
|
||||
const bridge = opts.bridge ?? createCliBridge(bridgeOpts);
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* native compaction step.
|
||||
*/
|
||||
|
||||
import { isDirectExecution } from '@waggle/hive-mind-shim-core';
|
||||
|
||||
import {
|
||||
pickStringFromObject,
|
||||
runHook,
|
||||
@@ -38,15 +40,6 @@ export async function runPreCompact(opts: Partial<HookRunOptions> = {}): Promise
|
||||
return runHook(preCompactHandler, { name: 'pre-compact', ...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) {
|
||||
if (isDirectExecution(import.meta.url)) {
|
||||
void runPreCompact();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
* output — the session starts as it would have without the shim.
|
||||
*/
|
||||
|
||||
import { recallPersonalAndWorkspace, type MemoryHit } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
isDirectExecution,
|
||||
recallPersonalAndWorkspace,
|
||||
type MemoryHit,
|
||||
} from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
pickStringFromObject,
|
||||
runHook,
|
||||
@@ -88,15 +92,6 @@ export async function runSessionStart(opts: Partial<HookRunOptions> = {}): Promi
|
||||
return runHook(sessionStartHandler, { name: 'session-start', ...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) {
|
||||
if (isDirectExecution(import.meta.url)) {
|
||||
void runSessionStart();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import {
|
||||
classifyImportance,
|
||||
encodeFrame,
|
||||
isDirectExecution,
|
||||
maybeEmitDiscovery,
|
||||
summarizeTurn,
|
||||
type HookEvent,
|
||||
@@ -34,7 +35,8 @@ export const stopHandler: HookHandler<StopPayload, undefined> = {
|
||||
const sessionId = pickStringFromObject(obj, 'session_id')
|
||||
?? pickStringFromObject(obj, 'sessionId')
|
||||
?? 'default';
|
||||
const response = pickStringFromObject(obj, 'response')
|
||||
const response = pickStringFromObject(obj, 'last_assistant_message')
|
||||
?? pickStringFromObject(obj, 'response')
|
||||
?? pickStringFromObject(obj, 'assistant_message')
|
||||
?? pickStringFromObject(obj, 'transcript')
|
||||
?? '';
|
||||
@@ -115,15 +117,6 @@ export async function runStop(opts: Partial<HookRunOptions> = {}): Promise<void>
|
||||
return runHook(stopHandler, { name: 'stop', ...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) {
|
||||
if (isDirectExecution(import.meta.url)) {
|
||||
void runStop();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* No stdout output: this hook is purely a side-effect on the .mind file.
|
||||
*/
|
||||
|
||||
import { encodeFrame, type HookEvent } from '@waggle/hive-mind-shim-core';
|
||||
import { encodeFrame, isDirectExecution, type HookEvent } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
pickStringFromObject,
|
||||
runHook,
|
||||
@@ -58,15 +58,6 @@ export async function runUserPromptSubmit(opts: Partial<HookRunOptions> = {}): P
|
||||
return runHook(userPromptSubmitHandler, { name: 'user-prompt-submit', ...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) {
|
||||
if (isDirectExecution(import.meta.url)) {
|
||||
void runUserPromptSubmit();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Programmatic install entry point.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Read existing `~/.claude/settings.json` (must exist + be valid JSON).
|
||||
* 1. Read existing `~/.claude/settings.json`, or create a minimal one when
|
||||
* Claude Code has not written it yet.
|
||||
* 2. Write a byte-identical backup at
|
||||
* `~/.claude/settings.json.hive-mind-backup.<timestamp>`.
|
||||
* 3. Compute the four hive-mind hook command strings (absolute paths
|
||||
@@ -13,9 +14,10 @@
|
||||
* 6. Drop a pointer file at `~/.claude/hive-mind-install.json` so a
|
||||
* future `uninstall` knows which backup to restore.
|
||||
*
|
||||
* Round-trip guarantee: the pre-install settings.json content equals
|
||||
* the byte-identical content written to the backup. `uninstall` simply
|
||||
* copies the backup over.
|
||||
* Round-trip guarantee: pre-existing settings content equals the
|
||||
* byte-identical content written to the backup. The pointer records whether
|
||||
* settings were pre-existing or installer-created so uninstall can restore
|
||||
* the former and remove the latter.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
@@ -46,7 +48,7 @@ export interface InstallResult {
|
||||
}
|
||||
|
||||
export interface InstallOptions extends ResolvePathsOptions {
|
||||
/** Per-hook timeout, seconds. Default 5. */
|
||||
/** Per-hook timeout override, seconds. Defaults: SessionStart 15; others 12. */
|
||||
hookTimeoutSeconds?: number;
|
||||
/** Override clock for deterministic tests. */
|
||||
now?: () => Date;
|
||||
@@ -62,12 +64,50 @@ export interface InstallOptions extends ResolvePathsOptions {
|
||||
cliPath?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOOK_TIMEOUT_S = 5;
|
||||
const DEFAULT_HOOK_TIMEOUT_S = 12;
|
||||
const DEFAULT_SESSION_START_TIMEOUT_S = 15;
|
||||
|
||||
interface ActiveInstallPointer {
|
||||
createdByUs: boolean;
|
||||
installedAt?: string;
|
||||
settingsBackup: string;
|
||||
}
|
||||
|
||||
async function ensureDir(p: string): Promise<void> {
|
||||
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function readActiveInstallPointer(pointerPath: string): Promise<ActiveInstallPointer | null> {
|
||||
if (!existsSync(pointerPath)) return null;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(await readFile(pointerPath, 'utf-8')) as unknown;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`existing install pointer at ${pointerPath} is unreadable: `
|
||||
+ (err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error(`existing install pointer at ${pointerPath} is malformed`);
|
||||
}
|
||||
const pointer = value as Record<string, unknown>;
|
||||
const settingsBackup = pointer['settings_backup'];
|
||||
const createdByUs = pointer['created_by_us'];
|
||||
if (typeof settingsBackup !== 'string' || !existsSync(settingsBackup)) {
|
||||
throw new Error(`existing install pointer at ${pointerPath} has no readable settings backup`);
|
||||
}
|
||||
if (createdByUs !== undefined && typeof createdByUs !== 'boolean') {
|
||||
throw new Error(`existing install pointer at ${pointerPath} has invalid settings ownership`);
|
||||
}
|
||||
const installedAt = pointer['installed_at'];
|
||||
return {
|
||||
createdByUs: createdByUs ?? false,
|
||||
settingsBackup,
|
||||
...(typeof installedAt === 'string' ? { installedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'claude-code-hooks/install' });
|
||||
// Use install.ts's own URL for hooks-dir derivation so that callers
|
||||
@@ -82,44 +122,65 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
||||
|
||||
log.info('install starting', { settings: paths.settingsPath, hooksDir: paths.hooksDir });
|
||||
|
||||
if (!existsSync(paths.settingsPath)) {
|
||||
const activeInstall = await readActiveInstallPointer(paths.pointerPath);
|
||||
const settingsExisted = existsSync(paths.settingsPath);
|
||||
if (activeInstall && !activeInstall.createdByUs && !settingsExisted) {
|
||||
throw new Error(
|
||||
`expected Claude Code settings at ${paths.settingsPath}, file not found. ` +
|
||||
`Run Claude Code at least once before installing this shim.`,
|
||||
`existing install pointer at ${paths.pointerPath} owns a pre-existing settings file, `
|
||||
+ `but ${paths.settingsPath} is missing; uninstall to restore it before reinstalling`,
|
||||
);
|
||||
}
|
||||
|
||||
const originalContent = await readFile(paths.settingsPath, 'utf-8');
|
||||
let parsed: ClaudeCodeSettings;
|
||||
try {
|
||||
parsed = JSON.parse(originalContent) as ClaudeCodeSettings;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`failed to parse existing ${paths.settingsPath} as JSON: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
const createdByUs = activeInstall?.createdByUs ?? !settingsExisted;
|
||||
let originalContent = '{}\n';
|
||||
let parsed: ClaudeCodeSettings = {};
|
||||
if (settingsExisted) {
|
||||
originalContent = await readFile(paths.settingsPath, 'utf-8');
|
||||
try {
|
||||
parsed = JSON.parse(originalContent) as ClaudeCodeSettings;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`failed to parse existing ${paths.settingsPath} as JSON: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDir(dirname(paths.pointerPath));
|
||||
const backupPath = backupPathFor(paths.settingsPath, now().toISOString());
|
||||
await writeFile(backupPath, originalContent, 'utf-8');
|
||||
log.info('settings backed up', { backupPath });
|
||||
|
||||
const cliPath = normalizeCliPath(opts.cliPath);
|
||||
await ensureDir(dirname(paths.pointerPath));
|
||||
if (!settingsExisted) {
|
||||
await writeFile(paths.settingsPath, originalContent, 'utf-8');
|
||||
log.info('minimal settings created', { settings: paths.settingsPath });
|
||||
}
|
||||
const backupPath = activeInstall?.settingsBackup
|
||||
?? backupPathFor(paths.settingsPath, now().toISOString());
|
||||
if (!activeInstall) {
|
||||
await writeFile(backupPath, originalContent, 'utf-8');
|
||||
log.info('settings backed up', { backupPath });
|
||||
} else {
|
||||
log.info('existing rollback state preserved', { backupPath, createdByUs });
|
||||
}
|
||||
|
||||
const requestedTimeout = opts.hookTimeoutSeconds;
|
||||
const entries = defaultHookEntries(
|
||||
paths.hooksDir,
|
||||
opts.hookTimeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_S,
|
||||
requestedTimeout ?? DEFAULT_HOOK_TIMEOUT_S,
|
||||
hookCommandFor,
|
||||
cliPath,
|
||||
);
|
||||
).map((entry) => (
|
||||
requestedTimeout === undefined && entry.basename === 'session-start'
|
||||
? { ...entry, timeout: DEFAULT_SESSION_START_TIMEOUT_S }
|
||||
: entry
|
||||
));
|
||||
const merged = mergeHiveHooks(parsed, entries);
|
||||
const mergedJson = JSON.stringify(merged, null, 2) + '\n';
|
||||
await writeFile(paths.settingsPath, mergedJson, 'utf-8');
|
||||
|
||||
const pointer: Record<string, unknown> = {
|
||||
version: '0.1.0',
|
||||
installed_at: now().toISOString(),
|
||||
installed_at: activeInstall?.installedAt ?? now().toISOString(),
|
||||
config_path: paths.settingsPath,
|
||||
settings_backup: backupPath,
|
||||
created_by_us: createdByUs,
|
||||
hooks_dir: paths.hooksDir,
|
||||
installed_hooks: entries.map((e) => e.basename),
|
||||
cli_path: cliPath ?? null,
|
||||
@@ -133,7 +194,7 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
||||
backupPath,
|
||||
pointerPath: paths.pointerPath,
|
||||
installedHooks: entries.map((e) => e.basename),
|
||||
alreadyInstalled: false,
|
||||
alreadyInstalled: activeInstall !== null,
|
||||
};
|
||||
if (cliPath !== undefined) result.cliPath = cliPath;
|
||||
return result;
|
||||
|
||||
@@ -57,16 +57,65 @@ function buildGroup(spec: HookEntrySpec): HookGroup {
|
||||
return group;
|
||||
}
|
||||
|
||||
function isHiveGroup(group: HookGroup | undefined): boolean {
|
||||
return !!group && group._hiveMindShim === HIVE_MIND_MARKER;
|
||||
function firstCommand(group: HookGroup | undefined): string | undefined {
|
||||
if (!group || !Array.isArray(group.hooks)) return undefined;
|
||||
const command = group.hooks[0]?.command;
|
||||
return typeof command === 'string' ? command : undefined;
|
||||
}
|
||||
|
||||
export function isHiveHookCommand(command: string | undefined, basename?: HookBasename): boolean {
|
||||
return hiveHookScriptPath(command, basename) !== undefined;
|
||||
}
|
||||
|
||||
export function generatedHookScriptPath(
|
||||
command: string | undefined,
|
||||
basename?: HookBasename,
|
||||
): string | undefined {
|
||||
if (!command) return undefined;
|
||||
|
||||
const pathNode = /^node\s+"([^"]+)"(?:\s|$)/i.exec(command);
|
||||
const pinnedNode = /^"([^"]+)"\s+"([^"]+)"(?:\s|$)/.exec(command);
|
||||
let scriptPath = pathNode?.[1];
|
||||
if (!scriptPath && pinnedNode?.[1] && pinnedNode[2]) {
|
||||
const executable = pinnedNode[1].replace(/\\/g, '/').split('/').at(-1)?.toLowerCase();
|
||||
if (executable === 'node' || executable === 'node.exe') scriptPath = pinnedNode[2];
|
||||
}
|
||||
if (!scriptPath) return undefined;
|
||||
|
||||
const normalized = scriptPath.replace(/\\/g, '/').toLowerCase();
|
||||
const basenames = basename ? [basename] : allHookBasenames();
|
||||
const generated = basenames.some((candidate) => normalized.endsWith(`/${candidate}.js`));
|
||||
return generated ? scriptPath : undefined;
|
||||
}
|
||||
|
||||
export function hiveHookScriptPath(
|
||||
command: string | undefined,
|
||||
basename?: HookBasename,
|
||||
): string | undefined {
|
||||
const scriptPath = generatedHookScriptPath(command, basename);
|
||||
if (!scriptPath) return undefined;
|
||||
|
||||
const normalized = scriptPath.replace(/\\/g, '/').toLowerCase();
|
||||
const basenames = basename ? [basename] : allHookBasenames();
|
||||
const owned = basenames.some((candidate) => normalized.endsWith(
|
||||
`/hive-mind-hooks-claude-code/dist/hooks/${candidate}.js`,
|
||||
));
|
||||
return owned ? scriptPath : undefined;
|
||||
}
|
||||
|
||||
export function isOwnedHiveGroup(group: HookGroup | undefined, basename?: HookBasename): boolean {
|
||||
return !!group && (
|
||||
group._hiveMindShim === HIVE_MIND_MARKER
|
||||
|| isHiveHookCommand(firstCommand(group), basename)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a NEW settings object with hive-mind hook entries appended to
|
||||
* each Claude Code event array. Existing entries are preserved.
|
||||
*
|
||||
* If a hive-mind entry for a given event is already present (matching
|
||||
* marker AND command path), it is replaced in place rather than
|
||||
* If a hive-mind entry for a given event is already present, its marker is
|
||||
* the ownership boundary, so it is replaced in place rather than
|
||||
* duplicated — supports re-running install for upgrades.
|
||||
*/
|
||||
export function mergeHiveHooks(
|
||||
@@ -82,16 +131,15 @@ export function mergeHiveHooks(
|
||||
const existingArr = nextHooks[eventKey] ? [...nextHooks[eventKey]] : [];
|
||||
const newGroup = buildGroup(spec);
|
||||
|
||||
let replaced = false;
|
||||
for (let i = 0; i < existingArr.length; i += 1) {
|
||||
const g = existingArr[i];
|
||||
if (isHiveGroup(g) && g.hooks[0]?.command === spec.command) {
|
||||
existingArr[i] = newGroup;
|
||||
replaced = true;
|
||||
break;
|
||||
const ownedIndex = existingArr.findIndex((group) => isOwnedHiveGroup(group, spec.basename));
|
||||
if (ownedIndex === -1) {
|
||||
existingArr.push(newGroup);
|
||||
} else {
|
||||
existingArr[ownedIndex] = newGroup;
|
||||
for (let i = existingArr.length - 1; i > ownedIndex; i -= 1) {
|
||||
if (isOwnedHiveGroup(existingArr[i], spec.basename)) existingArr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
if (!replaced) existingArr.push(newGroup);
|
||||
|
||||
nextHooks[eventKey] = existingArr;
|
||||
}
|
||||
@@ -107,7 +155,7 @@ export function mergeHiveHooks(
|
||||
export function hasHiveHooks(settings: ClaudeCodeSettings | undefined): boolean {
|
||||
if (!settings || !settings.hooks) return false;
|
||||
for (const groups of Object.values(settings.hooks)) {
|
||||
if (Array.isArray(groups) && groups.some(isHiveGroup)) return true;
|
||||
if (Array.isArray(groups) && groups.some((group) => isOwnedHiveGroup(group))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Programmatic uninstall entry point.
|
||||
*
|
||||
* Round-trip guarantee: after uninstall, `~/.claude/settings.json` is
|
||||
* byte-identical to the pre-install state. We achieve this by reading
|
||||
* the backup the installer wrote and copying it back. Uninstall refuses
|
||||
* to delete the backup unless the in-place readback matches the backup
|
||||
* content.
|
||||
* Round-trip guarantee: after uninstall, pre-existing settings are
|
||||
* byte-identical to their pre-install state, while a settings file created by
|
||||
* this installer is removed. Ownership is recorded in the install pointer.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
@@ -16,6 +14,7 @@ import { resolvePaths, type ResolvePathsOptions, type ShimPaths } from './paths.
|
||||
export interface UninstallResult {
|
||||
paths: ShimPaths;
|
||||
restoredFrom: string;
|
||||
settingsRemoved: boolean;
|
||||
pointerRemoved: boolean;
|
||||
backupRemoved: boolean;
|
||||
}
|
||||
@@ -29,7 +28,9 @@ export interface UninstallOptions extends ResolvePathsOptions {
|
||||
interface InstallPointer {
|
||||
version: string;
|
||||
installed_at: string;
|
||||
config_path?: string;
|
||||
settings_backup: string;
|
||||
created_by_us?: boolean;
|
||||
hooks_dir: string;
|
||||
installed_hooks: readonly string[];
|
||||
}
|
||||
@@ -37,7 +38,9 @@ interface InstallPointer {
|
||||
function isPointer(value: unknown): value is InstallPointer {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return typeof v['settings_backup'] === 'string';
|
||||
const createdByUs = v['created_by_us'];
|
||||
return typeof v['settings_backup'] === 'string'
|
||||
&& (createdByUs === undefined || typeof createdByUs === 'boolean');
|
||||
}
|
||||
|
||||
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
|
||||
@@ -69,18 +72,30 @@ export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallR
|
||||
}
|
||||
|
||||
const backupContent = await readFile(pointer.settings_backup, 'utf-8');
|
||||
await writeFile(paths.settingsPath, backupContent, 'utf-8');
|
||||
const settingsRemoved = pointer.created_by_us === true;
|
||||
if (settingsRemoved) {
|
||||
if (existsSync(paths.settingsPath)) await unlink(paths.settingsPath);
|
||||
if (existsSync(paths.settingsPath)) {
|
||||
throw new Error(
|
||||
`uninstall verification failed: installer-created ${paths.settingsPath} still exists. ` +
|
||||
`Backup was NOT removed; clean up manually if needed.`,
|
||||
);
|
||||
}
|
||||
log.info('installer-created settings removed', { settings: paths.settingsPath });
|
||||
} else {
|
||||
await writeFile(paths.settingsPath, backupContent, 'utf-8');
|
||||
|
||||
// Round-trip verification: read what we just wrote and compare bytes.
|
||||
const verify = await readFile(paths.settingsPath, 'utf-8');
|
||||
if (verify !== backupContent) {
|
||||
throw new Error(
|
||||
`uninstall verification failed: ${paths.settingsPath} content differs ` +
|
||||
`from backup ${pointer.settings_backup}. Backup was NOT removed; ` +
|
||||
`restore manually if needed.`,
|
||||
);
|
||||
// Round-trip verification: read what we just wrote and compare bytes.
|
||||
const verify = await readFile(paths.settingsPath, 'utf-8');
|
||||
if (verify !== backupContent) {
|
||||
throw new Error(
|
||||
`uninstall verification failed: ${paths.settingsPath} content differs ` +
|
||||
`from backup ${pointer.settings_backup}. Backup was NOT removed; ` +
|
||||
`restore manually if needed.`,
|
||||
);
|
||||
}
|
||||
log.info('settings restored byte-identical', { settings: paths.settingsPath });
|
||||
}
|
||||
log.info('settings restored byte-identical', { settings: paths.settingsPath });
|
||||
|
||||
let backupRemoved = false;
|
||||
if (cleanup) {
|
||||
@@ -92,6 +107,7 @@ export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallR
|
||||
return {
|
||||
paths,
|
||||
restoredFrom: pointer.settings_backup,
|
||||
settingsRemoved,
|
||||
pointerRemoved: true,
|
||||
backupRemoved,
|
||||
};
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
import { readFile, access } from 'node:fs/promises';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
|
||||
import {
|
||||
HIVE_MIND_MARKER,
|
||||
HOOK_EVENT_BY_BASENAME,
|
||||
generatedHookScriptPath,
|
||||
isOwnedHiveGroup,
|
||||
type ClaudeCodeSettings,
|
||||
type HookGroup,
|
||||
} from './settings-merger.js';
|
||||
|
||||
export interface VerifyCheck {
|
||||
@@ -44,15 +45,16 @@ async function fileReadable(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.R_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function findHiveGroup(groups: HookGroup[] | undefined, command: string): HookGroup | undefined {
|
||||
if (!Array.isArray(groups)) return undefined;
|
||||
return groups.find((g) => g._hiveMindShim === HIVE_MIND_MARKER && g.hooks[0]?.command === command);
|
||||
}
|
||||
|
||||
function isJsPath(p: string): boolean {
|
||||
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
|
||||
}
|
||||
|
||||
function sameResolvedPath(left: string, right: string): boolean {
|
||||
const a = resolve(left).replace(/\\/g, '/');
|
||||
const b = resolve(right).replace(/\\/g, '/');
|
||||
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
}
|
||||
|
||||
function probeCliVersion(
|
||||
cliPath: string,
|
||||
spawnImpl: typeof spawn,
|
||||
@@ -124,28 +126,25 @@ export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
|
||||
for (const basename of allHookBasenames()) {
|
||||
const eventKey = HOOK_EVENT_BY_BASENAME[basename];
|
||||
const groups = parsed.hooks?.[eventKey];
|
||||
const expectedCmdSuffix = `${basename}.js`;
|
||||
const found = Array.isArray(groups)
|
||||
? groups.find((g) => g._hiveMindShim === HIVE_MIND_MARKER && g.hooks[0]?.command.includes(expectedCmdSuffix))
|
||||
: undefined;
|
||||
const expectedScriptPath = join(paths.hooksDir, `${basename}.js`);
|
||||
const found = Array.isArray(groups) ? groups.find((group) => {
|
||||
if (!isOwnedHiveGroup(group, basename)) return false;
|
||||
const scriptPath = generatedHookScriptPath(group.hooks[0]?.command, basename);
|
||||
return scriptPath !== undefined && sameResolvedPath(scriptPath, expectedScriptPath);
|
||||
}) : undefined;
|
||||
if (!found) {
|
||||
checks.push({ name: `hooks.${eventKey} contains hive-mind entry`, ok: false });
|
||||
continue;
|
||||
}
|
||||
checks.push({ name: `hooks.${eventKey} contains hive-mind entry`, ok: true });
|
||||
|
||||
// Best-effort: extract path from `node "<path>"` and check file exists.
|
||||
const m = found.hooks[0]?.command.match(/node "([^"]+)"/);
|
||||
if (m && m[1]) {
|
||||
const ok = await fileReadable(m[1]);
|
||||
checks.push({
|
||||
name: `${basename}.js readable on disk`,
|
||||
ok,
|
||||
detail: m[1],
|
||||
});
|
||||
}
|
||||
// Also confirm we located the entry under the right top-level group key
|
||||
void findHiveGroup;
|
||||
const scriptPath = generatedHookScriptPath(found.hooks[0]?.command, basename);
|
||||
const ok = scriptPath !== undefined && await fileReadable(scriptPath);
|
||||
checks.push({
|
||||
name: `${basename}.js readable on disk`,
|
||||
ok,
|
||||
...(scriptPath !== undefined ? { detail: scriptPath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. hive-mind-cli responds to --help.
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLogger } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
buildHookBridgeOptions,
|
||||
parseHookArgs,
|
||||
pickStringField,
|
||||
pickStringFromObject,
|
||||
safeJsonParse,
|
||||
} from '../../src/hooks/_shared.js';
|
||||
|
||||
describe('buildHookBridgeOptions', () => {
|
||||
it('uses one bounded attempt for every Claude hook', () => {
|
||||
const logger = createLogger({ name: 'claude-hook-test' });
|
||||
const sessionStart = buildHookBridgeOptions(
|
||||
logger,
|
||||
'C:\\waggle\\hive-mind-cli.js',
|
||||
);
|
||||
expect(sessionStart).toMatchObject({
|
||||
logger,
|
||||
cli_path: 'C:\\waggle\\hive-mind-cli.js',
|
||||
timeout_ms: 10_000,
|
||||
max_retries: 0,
|
||||
});
|
||||
const stop = buildHookBridgeOptions(logger, 'C:\\waggle\\hive-mind-cli.js');
|
||||
expect(stop.timeout_ms).toBe(10_000);
|
||||
expect(stop.max_retries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsonParse', () => {
|
||||
it('returns {} for empty / whitespace input', () => {
|
||||
expect(safeJsonParse('')).toEqual({});
|
||||
|
||||
@@ -3,12 +3,39 @@ import { runStop, stopHandler } from '../../src/hooks/stop.js';
|
||||
import { makeHookCaptures, makeMockBridge } from './_test-helpers.js';
|
||||
|
||||
describe('stop handler', () => {
|
||||
it('extracts response from payload.response or payload.assistant_message', () => {
|
||||
it('extracts response from current and legacy Claude Stop payload fields', () => {
|
||||
expect(stopHandler.parse({ response: 'r' }).response).toBe('r');
|
||||
expect(stopHandler.parse({ assistant_message: 'a' }).response).toBe('a');
|
||||
expect(stopHandler.parse({ last_assistant_message: 'latest' }).response).toBe('latest');
|
||||
expect(stopHandler.parse({
|
||||
last_assistant_message: 'current',
|
||||
response: 'legacy',
|
||||
}).response).toBe('current');
|
||||
expect(stopHandler.parse({}).response).toBe('');
|
||||
});
|
||||
|
||||
it('saves the assistant turn from a Claude Code 2.1 host-shaped payload', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
await runStop({
|
||||
readStdin: async () => JSON.stringify({
|
||||
session_id: 'host-session',
|
||||
transcript_path: 'C:\\tmp\\transcript.jsonl',
|
||||
cwd: 'C:\\project',
|
||||
hook_event_name: 'Stop',
|
||||
stop_hook_active: false,
|
||||
last_assistant_message: 'CLAUDE_HOST_CANARY_OK',
|
||||
}),
|
||||
writeStdout: cap.writeStdout,
|
||||
exit: cap.exit,
|
||||
bridge,
|
||||
});
|
||||
|
||||
expect(bridge.saveMemory).toHaveBeenCalledTimes(1);
|
||||
expect(bridge.saveMemory.mock.calls[0][0].content).toContain('CLAUDE_HOST_CANARY_OK');
|
||||
expect(cap.exits).toEqual([0]);
|
||||
});
|
||||
|
||||
it('summarizes long responses and saves an important frame', async () => {
|
||||
const bridge = makeMockBridge();
|
||||
const cap = makeHookCaptures();
|
||||
|
||||
@@ -35,13 +35,20 @@ describe('install', () => {
|
||||
if (env) await cleanup(env);
|
||||
});
|
||||
|
||||
it('throws when settings.json is missing', async () => {
|
||||
it('creates minimal settings and records ownership when settings.json is missing', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-install-no-settings-'));
|
||||
try {
|
||||
await expect(install({
|
||||
const result = await install({
|
||||
home,
|
||||
hooksDir: join(home, 'dist', 'hooks'),
|
||||
})).rejects.toThrow(/settings/);
|
||||
});
|
||||
const settings = JSON.parse(await readFile(result.paths.settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
expect(Object.keys(settings)).toEqual(['hooks']);
|
||||
expect(settings.hooks?.SessionStart).toHaveLength(1);
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['config_path']).toBe(result.paths.settingsPath);
|
||||
expect(await readFile(result.backupPath, 'utf-8')).toBe('{}\n');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
@@ -81,12 +88,33 @@ describe('install', () => {
|
||||
expect(after.hooks?.PreCompact).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gives cold SessionStart more time than write hooks by default', async () => {
|
||||
env = await bootstrap({});
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
const timeouts = Object.values(after.hooks ?? {}).flatMap((groups) => (
|
||||
groups.map((group) => group.hooks[0]?.timeout)
|
||||
));
|
||||
expect(timeouts).toEqual([15, 12, 12, 12]);
|
||||
});
|
||||
|
||||
it('uses an explicit timeout override for every hook', async () => {
|
||||
env = await bootstrap({});
|
||||
await install({ home: env.home, hooksDir: env.hooksDir, hookTimeoutSeconds: 9 });
|
||||
const after = JSON.parse(await readFile(env.settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
const timeouts = Object.values(after.hooks ?? {}).flatMap((groups) => (
|
||||
groups.map((group) => group.hooks[0]?.timeout)
|
||||
));
|
||||
expect(timeouts).toEqual([9, 9, 9, 9]);
|
||||
});
|
||||
|
||||
it('drops a pointer file with the backup path + version', async () => {
|
||||
env = await bootstrap({});
|
||||
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['settings_backup']).toBe(result.backupPath);
|
||||
expect(pointer['created_by_us']).toBe(false);
|
||||
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
|
||||
expect(typeof pointer['version']).toBe('string');
|
||||
});
|
||||
|
||||
@@ -64,6 +64,59 @@ describe('mergeHiveHooks', () => {
|
||||
expect(merged2.hooks?.SessionStart?.[0].hooks[0].timeout).toBe(7);
|
||||
});
|
||||
|
||||
it('replaces stale marked entries when the install path changes', () => {
|
||||
const oldCommand = hookCommandFor('/old/dist/hooks', 'session-start', '/old/cli.js');
|
||||
const newCommand = hookCommandFor('/new/dist/hooks', 'session-start', '/new/cli.js');
|
||||
const merged1 = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]);
|
||||
const merged2 = mergeHiveHooks(merged1, [{ basename: 'session-start', command: newCommand, timeout: 7 }]);
|
||||
|
||||
const markedGroups = merged2.hooks?.SessionStart?.filter(
|
||||
(group) => group._hiveMindShim === HIVE_MIND_MARKER,
|
||||
);
|
||||
expect(markedGroups).toHaveLength(1);
|
||||
expect(markedGroups?.[0].hooks[0]).toEqual({
|
||||
type: 'command',
|
||||
command: newCommand,
|
||||
timeout: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes and replaces a hook after Claude strips the ownership marker', () => {
|
||||
const oldCommand = hookCommandFor('/old/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/old/cli.js');
|
||||
const newCommand = hookCommandFor('/new/hive-mind-hooks-claude-code/dist/hooks', 'session-start', '/new/cli.js');
|
||||
const installed = mergeHiveHooks({}, [{ basename: 'session-start', command: oldCommand, timeout: 5 }]);
|
||||
const normalized = structuredClone(installed);
|
||||
delete normalized.hooks?.SessionStart?.[0]._hiveMindShim;
|
||||
|
||||
expect(hasHiveHooks(normalized)).toBe(true);
|
||||
const upgraded = mergeHiveHooks(normalized, [{ basename: 'session-start', command: newCommand, timeout: 7 }]);
|
||||
expect(upgraded.hooks?.SessionStart).toHaveLength(1);
|
||||
expect(upgraded.hooks?.SessionStart?.[0].hooks[0].command).toBe(newCommand);
|
||||
});
|
||||
|
||||
it('does not claim an unrelated command that only mentions a Waggle hook path', () => {
|
||||
const unrelated: ClaudeCodeSettings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: 'echo "C:\\archive\\hive-mind-hooks-claude-code\\dist\\hooks\\session-start.js"',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
expect(hasHiveHooks(unrelated)).toBe(false);
|
||||
const command = hookCommandFor(
|
||||
'/new/hive-mind-hooks-claude-code/dist/hooks',
|
||||
'session-start',
|
||||
'/new/cli.js',
|
||||
);
|
||||
const merged = mergeHiveHooks(unrelated, [{ basename: 'session-start', command, timeout: 5 }]);
|
||||
expect(merged.hooks?.SessionStart).toHaveLength(2);
|
||||
expect(merged.hooks?.SessionStart?.[0]).toEqual(unrelated.hooks?.SessionStart?.[0]);
|
||||
});
|
||||
|
||||
it('preserves unrelated top-level fields', () => {
|
||||
const merged = mergeHiveHooks(
|
||||
{ env: { SOMETHING: '1' }, statusLine: { type: 'command', command: 'foo' }, hooks: {} } as ClaudeCodeSettings,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { install } from '../src/install.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
import type { ClaudeCodeSettings } from '../src/settings-merger.js';
|
||||
|
||||
interface TestEnv {
|
||||
@@ -50,7 +51,7 @@ describe('uninstall', () => {
|
||||
.rejects.toThrow(/malformed/);
|
||||
});
|
||||
|
||||
it('install + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
|
||||
it('reinstall + uninstall round-trip is SHA-256 identical to pre-install state', async () => {
|
||||
const initialSettings: ClaudeCodeSettings = {
|
||||
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
|
||||
hooks: {
|
||||
@@ -69,6 +70,7 @@ describe('uninstall', () => {
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterInstall = await readFile(env.settingsPath, 'utf-8');
|
||||
expect(sha256(afterInstall)).not.toBe(preHash);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
|
||||
await uninstall({ home: env.home, hooksDir: env.hooksDir });
|
||||
const afterUninstall = await readFile(env.settingsPath, 'utf-8');
|
||||
@@ -76,6 +78,36 @@ describe('uninstall', () => {
|
||||
expect(afterUninstall).toBe(preInstall);
|
||||
});
|
||||
|
||||
it('install + verify + uninstall round-trips an absent settings file', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hmc-uninstall-fresh-'));
|
||||
const hooksDir = resolve(home, 'fake-dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
for (const basename of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
await writeFile(join(hooksDir, `${basename}.js`), '/* mock hook */', 'utf-8');
|
||||
}
|
||||
const cliPath = join(home, 'fake-cli.mjs');
|
||||
await writeFile(cliPath, 'process.stdout.write("ok\\n");', 'utf-8');
|
||||
env = {
|
||||
home,
|
||||
hooksDir,
|
||||
settingsPath: join(home, '.claude', 'settings.json'),
|
||||
pointerPath: join(home, '.claude', 'hive-mind-install.json'),
|
||||
};
|
||||
|
||||
expect(existsSync(env.settingsPath)).toBe(false);
|
||||
const installed = await install({ home, hooksDir, cliPath });
|
||||
const reinstalled = await install({ home, hooksDir, cliPath });
|
||||
expect(reinstalled.alreadyInstalled).toBe(true);
|
||||
expect(reinstalled.backupPath).toBe(installed.backupPath);
|
||||
await expect(verify({ home, hooksDir })).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const removed = await uninstall({ home, hooksDir });
|
||||
expect(removed.settingsRemoved).toBe(true);
|
||||
expect(existsSync(env.settingsPath)).toBe(false);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
expect(existsSync(installed.backupPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the backup file by default after restore', async () => {
|
||||
env = await bootstrap({});
|
||||
const result = await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
@@ -20,7 +20,7 @@ async function bootstrap(initial: ClaudeCodeSettings, withHookFiles: boolean): P
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
const settingsPath = join(claudeDir, 'settings.json');
|
||||
await writeFile(settingsPath, JSON.stringify(initial, null, 2), 'utf-8');
|
||||
const hooksDir = join(home, 'fake-dist', 'hooks');
|
||||
const hooksDir = join(home, 'hive-mind-hooks-claude-code', 'dist', 'hooks');
|
||||
await mkdir(hooksDir, { recursive: true });
|
||||
if (withHookFiles) {
|
||||
for (const b of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
@@ -48,6 +48,7 @@ function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: strin
|
||||
describe('verify', () => {
|
||||
const envs: TestEnv[] = [];
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -90,6 +91,25 @@ describe('verify', () => {
|
||||
expect(cliCheck?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('passes after Claude normalizes away marker keys but preserves hook commands', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const settingsPath = join(env.home, '.claude', 'settings.json');
|
||||
const settings = JSON.parse(await readFile(settingsPath, 'utf-8')) as ClaudeCodeSettings;
|
||||
for (const groups of Object.values(settings.hooks ?? {})) {
|
||||
for (const group of groups) delete group._hiveMindShim;
|
||||
}
|
||||
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: env.hooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reports CLI unreachable when the spawn exits non-zero', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
@@ -142,4 +162,44 @@ describe('verify', () => {
|
||||
const fileCheck = result.checks.find((c) => c.name.includes('readable on disk'));
|
||||
expect(fileCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('flags missing hook scripts when the installed command pins a quoted Windows Node path', async () => {
|
||||
vi.stubEnv('WAGGLE_HOOK_NODE_PATH', 'C:\\Program Files\\nodejs\\node.exe');
|
||||
const env = await bootstrap({}, false);
|
||||
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 fileChecks = result.checks.filter((c) => c.name.includes('readable on disk'));
|
||||
expect(fileChecks).toHaveLength(4);
|
||||
expect(fileChecks.every((check) => check.ok === false)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a readable hook command from a stale install directory', async () => {
|
||||
const env = await bootstrap({}, true);
|
||||
envs.push(env);
|
||||
await install({ home: env.home, hooksDir: env.hooksDir });
|
||||
const currentHooksDir = join(env.home, 'current', 'hive-mind-hooks-claude-code', 'dist', 'hooks');
|
||||
await mkdir(currentHooksDir, { recursive: true });
|
||||
for (const basename of ['session-start', 'user-prompt-submit', 'stop', 'pre-compact']) {
|
||||
await writeFile(join(currentHooksDir, `${basename}.js`), '/* current hook */', 'utf-8');
|
||||
}
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
hooksDir: currentHooksDir,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.filter((check) => (
|
||||
!check.ok && check.name.includes('contains hive-mind entry')
|
||||
))).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user