This commit is contained in:
19
packages/hive-mind-hooks-claude-desktop/LICENSE
Normal file
19
packages/hive-mind-hooks-claude-desktop/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
|
||||
9
packages/hive-mind-hooks-claude-desktop/README.md
Normal file
9
packages/hive-mind-hooks-claude-desktop/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# @waggle/hive-mind-hooks-claude-desktop
|
||||
|
||||
> **STUB.** Wave 2/3 implementation pending.
|
||||
|
||||
This package is a placeholder in the monorepo migration so that subtree-split + dependency-graph tooling sees the package boundary. Runtime functionality lands in a future sprint when the `claude-desktop` client's hook surface is implemented.
|
||||
|
||||
See `packages/hive-mind-hooks-claude-code` for the Wave 1 reference shape.
|
||||
|
||||
License: Apache-2.0.
|
||||
53
packages/hive-mind-hooks-claude-desktop/package.json
Normal file
53
packages/hive-mind-hooks-claude-desktop/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@waggle/hive-mind-hooks-claude-desktop",
|
||||
"version": "0.0.1",
|
||||
"description": "Claude Desktop MCP bridge for hive-mind. Registers the waggle-memory MCP server in claude_desktop_config.json with reversible create-if-missing installation, byte-identical uninstall, and a surgical fallback when the config is edited after install.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"claude-desktop-hooks": "dist/bin/claude-desktop-hooks.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.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-claude-desktop/tests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waggle/hive-mind-hooks-core": "*",
|
||||
"@waggle/hive-mind-shim-core": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/marolinik/waggle-os.git",
|
||||
"directory": "packages/hive-mind-hooks-claude-desktop"
|
||||
},
|
||||
"homepage": "https://github.com/marolinik/waggle-os/tree/main/packages/hive-mind-hooks-claude-desktop#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/marolinik/waggle-os/issues"
|
||||
},
|
||||
"author": "Egzakta Group d.o.o. <hello@egzakta.com> (https://egzakta.com)",
|
||||
"keywords": [
|
||||
"claude-desktop",
|
||||
"hive-mind",
|
||||
"memory",
|
||||
"ai",
|
||||
"hook",
|
||||
"mcp"
|
||||
],
|
||||
"types": "dist/index.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/** CLI for the Claude Desktop waggle-memory MCP bridge. */
|
||||
|
||||
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: claude-desktop-hooks <command> [options]',
|
||||
'',
|
||||
'Commands:',
|
||||
' install Register waggle-memory in claude_desktop_config.json.',
|
||||
' uninstall Restore the original config or remove only waggle-memory.',
|
||||
' verify Check the config, MCP entry, and install pointer.',
|
||||
'',
|
||||
'Options:',
|
||||
' --help, -h Show this help.',
|
||||
' --config-dir <PATH> Override the Claude Desktop config directory.',
|
||||
' --mcp-entry <PATH> Override waggle-memory-mcp/dist/index.js.',
|
||||
' --cli-path <PATH> Accepted for hook-runtime contract parity; recorded',
|
||||
' in the pointer, not used by the MCP bridge.',
|
||||
'',
|
||||
'Repo: https://github.com/marolinik/waggle-os',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function printInstallSummary(result: InstallResult): void {
|
||||
process.stdout.write([
|
||||
'hive-mind/claude-desktop-hooks: install',
|
||||
` - config: ${result.paths.configPath}`,
|
||||
` - backup: ${result.backupPath ?? '(none — config created by us)'}`,
|
||||
` - pointer: ${result.pointerPath}`,
|
||||
` - MCP server: ${result.serverName}`,
|
||||
` - MCP entry: ${result.mcpEntry}`,
|
||||
'',
|
||||
'Restart Claude Desktop for the MCP server registration to take effect.',
|
||||
'Run "claude-desktop-hooks verify" to inspect, or uninstall to revert.',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function printUninstallSummary(result: UninstallResult): void {
|
||||
process.stdout.write([
|
||||
'hive-mind/claude-desktop-hooks: uninstall',
|
||||
` - config: ${result.paths.configPath}`,
|
||||
` - restored from: ${result.restoredFrom ?? '(none)'}`,
|
||||
` - created removed: ${result.createdRemoved ? 'yes' : 'no'}`,
|
||||
` - backup removed: ${result.backupRemoved ? 'yes' : 'no (kept on disk)'}`,
|
||||
` - surgical removal: ${result.surgical ? 'yes' : 'no'}`,
|
||||
'',
|
||||
'Restart Claude Desktop for the change to take effect.',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function printVerifySummary(result: VerifyResult): void {
|
||||
const lines: string[] = ['hive-mind/claude-desktop-hooks: verify'];
|
||||
for (const check of result.checks) {
|
||||
const tag = check.ok ? 'PASS' : 'FAIL';
|
||||
const detail = check.detail ? ` — ${check.detail}` : '';
|
||||
lines.push(` [${tag}] ${check.name}${detail}`);
|
||||
}
|
||||
lines.push('', result.ok ? 'All checks passed.' : 'One or more checks failed.', '');
|
||||
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 configDir = typeof flags['config-dir'] === 'string' ? flags['config-dir'] : undefined;
|
||||
const mcpEntry = typeof flags['mcp-entry'] === 'string' ? flags['mcp-entry'] : undefined;
|
||||
const cliPath = typeof flags['cli-path'] === 'string' ? flags['cli-path'] : undefined;
|
||||
const baseOpts = {
|
||||
...(configDir !== undefined ? { configDir } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
if (command === 'install') {
|
||||
const result = await install({
|
||||
...baseOpts,
|
||||
...(mcpEntry !== undefined ? { mcpEntry } : {}),
|
||||
...(cliPath !== undefined ? { cliPath } : {}),
|
||||
});
|
||||
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();
|
||||
33
packages/hive-mind-hooks-claude-desktop/src/index.ts
Normal file
33
packages/hive-mind-hooks-claude-desktop/src/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @waggle/hive-mind-hooks-claude-desktop — public MCP-bridge API.
|
||||
*/
|
||||
|
||||
export { MCP_SERVER_NAME } from './install.js';
|
||||
|
||||
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 {
|
||||
ClaudeDesktopPaths,
|
||||
ResolvePathsOptions,
|
||||
} from './paths.js';
|
||||
export {
|
||||
resolvePaths,
|
||||
resolveMcpEntry,
|
||||
} from './paths.js';
|
||||
151
packages/hive-mind-hooks-claude-desktop/src/install.ts
Normal file
151
packages/hive-mind-hooks-claude-desktop/src/install.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
backupByteIdentical,
|
||||
normalizeCliPath,
|
||||
readPointer,
|
||||
writePointer,
|
||||
type InstallPointer,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import {
|
||||
resolveMcpEntry,
|
||||
resolvePaths,
|
||||
type ClaudeDesktopPaths,
|
||||
type ResolvePathsOptions,
|
||||
} from './paths.js';
|
||||
|
||||
export const MCP_SERVER_NAME = 'waggle-memory';
|
||||
|
||||
export interface InstallOptions extends ResolvePathsOptions {
|
||||
mcpEntry?: string;
|
||||
cliPath?: string;
|
||||
now?: () => Date;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
paths: ClaudeDesktopPaths;
|
||||
backupPath: string | null;
|
||||
pointerPath: string;
|
||||
createdByUs: boolean;
|
||||
serverName: typeof MCP_SERVER_NAME;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
const POINTER_VERSION = '0.1.0';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'claude-desktop-hooks/install' });
|
||||
const paths = resolvePaths(opts);
|
||||
const entry = resolveMcpEntry(opts);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
'cannot resolve waggle-memory-mcp/dist/index.js — ' +
|
||||
'build packages/memory-mcp first or pass --mcp-entry <path>',
|
||||
);
|
||||
}
|
||||
|
||||
const configExists = existsSync(paths.configPath);
|
||||
let config: Record<string, unknown> = {};
|
||||
if (configExists) {
|
||||
const raw = await readFile(paths.configPath, 'utf-8');
|
||||
try {
|
||||
config = asRecord(JSON.parse(raw)) ?? {};
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`failed to parse existing ${paths.configPath} as JSON: ` +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingServers = asRecord(config['mcpServers']);
|
||||
const pointerExists = existsSync(paths.pointerPath);
|
||||
if (
|
||||
configExists
|
||||
&& existingServers !== undefined
|
||||
&& Object.prototype.hasOwnProperty.call(existingServers, MCP_SERVER_NAME)
|
||||
&& !pointerExists
|
||||
) {
|
||||
throw new Error(
|
||||
`a '${MCP_SERVER_NAME}' MCP server entry already exists in ${paths.configPath} ` +
|
||||
'and was not installed by this tool; remove or rename it first',
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await mkdir(dirname(paths.pointerPath), { recursive: true });
|
||||
|
||||
const now = opts.now ?? ((): Date => new Date());
|
||||
const installedAt = now().toISOString();
|
||||
let backupPath: string | null;
|
||||
let createdByUs: boolean;
|
||||
|
||||
if (pointerExists) {
|
||||
const originalPointer = await readPointer(paths.pointerPath);
|
||||
backupPath = originalPointer.settings_backup;
|
||||
createdByUs = originalPointer.created_by_us;
|
||||
} else {
|
||||
const backup = await backupByteIdentical(paths.configPath, installedAt);
|
||||
backupPath = backup.backupPath;
|
||||
createdByUs = !backup.preExisted;
|
||||
}
|
||||
|
||||
const cliPath = normalizeCliPath(opts.cliPath);
|
||||
const serverEntry = {
|
||||
command: process.env.WAGGLE_HOOK_NODE_PATH?.trim() || process.execPath,
|
||||
args: [entry],
|
||||
};
|
||||
const next = {
|
||||
...config,
|
||||
mcpServers: {
|
||||
...(existingServers ?? {}),
|
||||
[MCP_SERVER_NAME]: serverEntry,
|
||||
},
|
||||
};
|
||||
const bytes = JSON.stringify(next, null, 2) + '\n';
|
||||
await writeFile(paths.configPath, bytes, 'utf-8');
|
||||
const installedSha = createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
const pointer: InstallPointer = {
|
||||
version: POINTER_VERSION,
|
||||
installed_at: installedAt,
|
||||
config_path: paths.configPath,
|
||||
settings_backup: backupPath,
|
||||
created_by_us: createdByUs,
|
||||
hooks_dir: null,
|
||||
installed_hooks: [`mcp:${MCP_SERVER_NAME}`],
|
||||
cli_path: cliPath ?? null,
|
||||
extra: {
|
||||
mcp_server_name: MCP_SERVER_NAME,
|
||||
mcp_entry: entry,
|
||||
mcp_command: serverEntry.command,
|
||||
installed_sha256: installedSha,
|
||||
},
|
||||
};
|
||||
await writePointer(paths.pointerPath, pointer);
|
||||
|
||||
log.info('install complete', {
|
||||
config: paths.configPath,
|
||||
server: MCP_SERVER_NAME,
|
||||
createdByUs,
|
||||
});
|
||||
|
||||
return {
|
||||
paths,
|
||||
backupPath,
|
||||
pointerPath: paths.pointerPath,
|
||||
createdByUs,
|
||||
serverName: MCP_SERVER_NAME,
|
||||
mcpEntry: entry,
|
||||
};
|
||||
}
|
||||
72
packages/hive-mind-hooks-claude-desktop/src/paths.ts
Normal file
72
packages/hive-mind-hooks-claude-desktop/src/paths.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { delimiter, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export interface ClaudeDesktopPaths {
|
||||
claudeConfigDir: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
}
|
||||
|
||||
export interface ResolvePathsOptions {
|
||||
home?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
export function resolvePaths(opts: ResolvePathsOptions = {}): ClaudeDesktopPaths {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const home = opts.home ?? homedir();
|
||||
|
||||
let defaultConfigDir: string;
|
||||
if (platform === 'win32') {
|
||||
const roaming = opts.home !== undefined
|
||||
? join(home, 'AppData', 'Roaming')
|
||||
: (process.env.APPDATA ?? join(home, 'AppData', 'Roaming'));
|
||||
defaultConfigDir = join(roaming, 'Claude');
|
||||
} else if (platform === 'darwin') {
|
||||
defaultConfigDir = join(home, 'Library', 'Application Support', 'Claude');
|
||||
} else {
|
||||
defaultConfigDir = join(home, '.config', 'Claude');
|
||||
}
|
||||
|
||||
const claudeConfigDir = opts.configDir
|
||||
?? process.env.WAGGLE_CLAUDE_DESKTOP_CONFIG_DIR
|
||||
?? defaultConfigDir;
|
||||
const configPath = join(claudeConfigDir, 'claude_desktop_config.json');
|
||||
const pointerPath = join(home, '.waggle', 'claude-desktop', 'hive-mind-install.json');
|
||||
|
||||
return { claudeConfigDir, configPath, pointerPath };
|
||||
}
|
||||
|
||||
export function resolveMcpEntry(opts: { mcpEntry?: string } = {}): string | undefined {
|
||||
const configured = opts.mcpEntry ?? process.env.WAGGLE_MEMORY_MCP_ENTRY;
|
||||
if (configured !== undefined) {
|
||||
const trimmed = configured.trim();
|
||||
return trimmed.length > 0 ? resolve(trimmed) : undefined;
|
||||
}
|
||||
|
||||
const nodePathRoots = (process.env.NODE_PATH ?? '')
|
||||
.split(delimiter)
|
||||
.map((root) => root.trim())
|
||||
.filter((root) => root.length > 0);
|
||||
const moduleNodeModules = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'node_modules',
|
||||
);
|
||||
const roots = [
|
||||
...nodePathRoots,
|
||||
join(process.cwd(), 'node_modules'),
|
||||
moduleNodeModules,
|
||||
];
|
||||
|
||||
for (const root of roots) {
|
||||
const candidate = join(root, 'waggle-memory-mcp', 'dist', 'index.js');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
101
packages/hive-mind-hooks-claude-desktop/src/uninstall.ts
Normal file
101
packages/hive-mind-hooks-claude-desktop/src/uninstall.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
readPointer,
|
||||
restoreFromBackup,
|
||||
} from '@waggle/hive-mind-hooks-core';
|
||||
import { MCP_SERVER_NAME } from './install.js';
|
||||
import {
|
||||
resolvePaths,
|
||||
type ClaudeDesktopPaths,
|
||||
type ResolvePathsOptions,
|
||||
} from './paths.js';
|
||||
|
||||
export interface UninstallOptions extends ResolvePathsOptions {
|
||||
logger?: Logger;
|
||||
cleanupBackup?: boolean;
|
||||
}
|
||||
|
||||
export interface UninstallResult {
|
||||
paths: ClaudeDesktopPaths;
|
||||
restoredFrom: string | null;
|
||||
createdRemoved: boolean;
|
||||
backupRemoved: boolean;
|
||||
surgical: boolean;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function uninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'claude-desktop-hooks/uninstall' });
|
||||
const paths = resolvePaths(opts);
|
||||
const pointer = await readPointer(paths.pointerPath);
|
||||
|
||||
let restoredFrom: string | null = null;
|
||||
let createdRemoved = false;
|
||||
let backupRemoved = false;
|
||||
let surgical = false;
|
||||
|
||||
if (existsSync(paths.configPath)) {
|
||||
const currentBytes = await readFile(paths.configPath);
|
||||
const currentSha = createHash('sha256').update(currentBytes).digest('hex');
|
||||
const extra = asRecord(pointer.extra);
|
||||
const installedSha = typeof extra?.['installed_sha256'] === 'string'
|
||||
? extra['installed_sha256']
|
||||
: undefined;
|
||||
|
||||
if (installedSha !== undefined && currentSha === installedSha) {
|
||||
const restored = await restoreFromBackup({
|
||||
configPath: paths.configPath,
|
||||
pointer,
|
||||
cleanupBackup: opts.cleanupBackup ?? true,
|
||||
});
|
||||
restoredFrom = restored.restoredFrom;
|
||||
createdRemoved = restored.createdRemoved;
|
||||
backupRemoved = restored.backupRemoved;
|
||||
} else {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = asRecord(JSON.parse(currentBytes.toString('utf-8'))) ?? {};
|
||||
} catch (err) {
|
||||
const backup = pointer.settings_backup ?? '(no backup available)';
|
||||
throw new Error(
|
||||
`failed to parse edited ${paths.configPath} as JSON; backup retained at ${backup} ` +
|
||||
`for manual recovery: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const servers = asRecord(parsed['mcpServers']) ?? {};
|
||||
const remainingServers = Object.fromEntries(
|
||||
Object.entries(servers).filter(([name]) => name !== MCP_SERVER_NAME),
|
||||
);
|
||||
const next = {
|
||||
...parsed,
|
||||
mcpServers: remainingServers,
|
||||
};
|
||||
await writeFile(paths.configPath, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
||||
surgical = true;
|
||||
}
|
||||
}
|
||||
|
||||
await unlink(paths.pointerPath);
|
||||
log.info('uninstall complete', {
|
||||
config: paths.configPath,
|
||||
surgical,
|
||||
backupRemoved,
|
||||
});
|
||||
|
||||
return {
|
||||
paths,
|
||||
restoredFrom,
|
||||
createdRemoved,
|
||||
backupRemoved,
|
||||
surgical,
|
||||
};
|
||||
}
|
||||
159
packages/hive-mind-hooks-claude-desktop/src/verify.ts
Normal file
159
packages/hive-mind-hooks-claude-desktop/src/verify.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import { MCP_SERVER_NAME } from './install.js';
|
||||
import { resolvePaths, type ResolvePathsOptions } from './paths.js';
|
||||
|
||||
export interface VerifyCheck {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
ok: boolean;
|
||||
checks: VerifyCheck[];
|
||||
}
|
||||
|
||||
export interface VerifyOptions extends ResolvePathsOptions {
|
||||
logger?: Logger;
|
||||
spawnImpl?: typeof spawn;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function fileReadable(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function probeEntrySyntax(
|
||||
entry: string,
|
||||
spawnImpl: typeof spawn,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const child = spawnImpl(
|
||||
process.execPath,
|
||||
['--check', entry],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* already exited */ }
|
||||
resolve({ ok: false, output: 'timed out checking waggle-memory-mcp entry' });
|
||||
}, timeoutMs);
|
||||
child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
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 error = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
|
||||
resolve({ ok: code === 0, output: code === 0 ? out : error });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'claude-desktop-hooks/verify' });
|
||||
const paths = resolvePaths(opts);
|
||||
const checks: VerifyCheck[] = [];
|
||||
|
||||
if (!existsSync(paths.configPath)) {
|
||||
checks.push({
|
||||
name: 'claude_desktop_config.json exists',
|
||||
ok: false,
|
||||
detail: paths.configPath,
|
||||
});
|
||||
return { ok: false, checks };
|
||||
}
|
||||
checks.push({
|
||||
name: 'claude_desktop_config.json exists',
|
||||
ok: true,
|
||||
detail: paths.configPath,
|
||||
});
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = asRecord(JSON.parse(await readFile(paths.configPath, 'utf-8'))) ?? {};
|
||||
checks.push({ name: 'config parses as JSON', ok: true });
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
name: 'config parses as JSON',
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { ok: false, checks };
|
||||
}
|
||||
|
||||
const servers = asRecord(parsed['mcpServers']);
|
||||
const containsEntry = servers !== undefined
|
||||
&& Object.prototype.hasOwnProperty.call(servers, MCP_SERVER_NAME);
|
||||
checks.push({
|
||||
name: `mcpServers contains '${MCP_SERVER_NAME}' entry`,
|
||||
ok: containsEntry,
|
||||
});
|
||||
|
||||
const serverEntry = containsEntry ? asRecord(servers[MCP_SERVER_NAME]) : undefined;
|
||||
const command = serverEntry?.['command'];
|
||||
const args = serverEntry?.['args'];
|
||||
const mcpEntry = Array.isArray(args) && typeof args[0] === 'string' ? args[0] : undefined;
|
||||
const pointsAtEntry = typeof command === 'string' && command.trim().length > 0
|
||||
&& mcpEntry !== undefined;
|
||||
checks.push({
|
||||
name: 'server entry points at waggle-memory-mcp',
|
||||
ok: pointsAtEntry,
|
||||
});
|
||||
|
||||
const readable = mcpEntry !== undefined && await fileReadable(mcpEntry);
|
||||
checks.push({
|
||||
name: 'memory-mcp entry readable on disk',
|
||||
ok: readable,
|
||||
detail: mcpEntry ?? 'missing args[0]',
|
||||
});
|
||||
|
||||
let syntaxProbe = { ok: false, output: 'missing args[0]' };
|
||||
if (mcpEntry !== undefined) {
|
||||
syntaxProbe = await probeEntrySyntax(mcpEntry, opts.spawnImpl ?? spawn, 4000);
|
||||
}
|
||||
checks.push({
|
||||
name: 'memory-mcp entry parses (node --check)',
|
||||
ok: syntaxProbe.ok,
|
||||
detail: syntaxProbe.output,
|
||||
});
|
||||
|
||||
checks.push({
|
||||
name: 'install pointer present',
|
||||
ok: existsSync(paths.pointerPath),
|
||||
detail: paths.pointerPath,
|
||||
});
|
||||
|
||||
const ok = checks.every((check) => check.ok);
|
||||
log.info('verify complete', {
|
||||
ok,
|
||||
total: checks.length,
|
||||
failed: checks.filter((check) => !check.ok).length,
|
||||
});
|
||||
return { ok, checks };
|
||||
}
|
||||
155
packages/hive-mind-hooks-claude-desktop/tests/install.test.ts
Normal file
155
packages/hive-mind-hooks-claude-desktop/tests/install.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { install, MCP_SERVER_NAME } from '../src/install.js';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial?: Record<string, unknown>): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-install-'));
|
||||
const mcpEntry = join(home, 'memory-mcp.js');
|
||||
await writeFile(mcpEntry, 'export {};\n', 'utf-8');
|
||||
if (initial !== undefined) {
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await writeFile(paths.configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
return { home, mcpEntry };
|
||||
}
|
||||
|
||||
function installOpts(env: TestEnv, mcpEntry = env.mcpEntry) {
|
||||
return {
|
||||
home: env.home,
|
||||
platform: 'linux' as const,
|
||||
mcpEntry,
|
||||
};
|
||||
}
|
||||
|
||||
describe('install (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a config and ownership pointer on a fresh install', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
|
||||
const result = await install({
|
||||
...installOpts(env),
|
||||
cliPath: ' /opt/hive-mind-cli/dist/index.js ',
|
||||
});
|
||||
const config = JSON.parse(await readFile(result.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, { command: string; args: string[] }>;
|
||||
};
|
||||
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
|
||||
expect(result.createdByUs).toBe(true);
|
||||
expect(result.backupPath).toBeNull();
|
||||
expect(config.mcpServers[MCP_SERVER_NAME]).toEqual({
|
||||
command: process.env.WAGGLE_HOOK_NODE_PATH?.trim() || process.execPath,
|
||||
args: [env.mcpEntry],
|
||||
});
|
||||
expect(Object.keys(config.mcpServers[MCP_SERVER_NAME])).toEqual(['command', 'args']);
|
||||
expect(pointer['created_by_us']).toBe(true);
|
||||
expect(pointer['settings_backup']).toBeNull();
|
||||
expect(pointer['hooks_dir']).toBeNull();
|
||||
expect(pointer['installed_hooks']).toEqual(['mcp:waggle-memory']);
|
||||
expect(pointer['cli_path']).toBe('/opt/hive-mind-cli/dist/index.js');
|
||||
});
|
||||
|
||||
it('backs up a pre-existing config byte-identically and preserves other servers', async () => {
|
||||
const existingServer = {
|
||||
command: 'python',
|
||||
args: ['server.py'],
|
||||
env: { KEEP_ME: 'yes' },
|
||||
};
|
||||
const env = await bootstrap({
|
||||
theme: 'dark',
|
||||
mcpServers: { userServer: existingServer },
|
||||
});
|
||||
homes.push(env.home);
|
||||
const paths = resolvePaths({ home: env.home, platform: 'linux' });
|
||||
const originalBytes = await readFile(paths.configPath);
|
||||
|
||||
const result = await install(installOpts(env));
|
||||
const config = JSON.parse(await readFile(paths.configPath, 'utf-8')) as {
|
||||
theme: string;
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(result.createdByUs).toBe(false);
|
||||
expect(result.backupPath).not.toBeNull();
|
||||
expect(await readFile(result.backupPath as string)).toEqual(originalBytes);
|
||||
expect(config.theme).toBe('dark');
|
||||
expect(config.mcpServers['userServer']).toEqual(existingServer);
|
||||
expect(config.mcpServers[MCP_SERVER_NAME]).toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses to clobber a foreign 'waggle-memory' entry without a pointer", async () => {
|
||||
const foreignEntry = { command: 'node', args: ['/user/server.js'] };
|
||||
const env = await bootstrap({ mcpServers: { [MCP_SERVER_NAME]: foreignEntry } });
|
||||
homes.push(env.home);
|
||||
|
||||
await expect(install(installOpts(env))).rejects.toThrow(
|
||||
/was not installed by this tool; remove or rename it first/,
|
||||
);
|
||||
const paths = resolvePaths({ home: env.home, platform: 'linux' });
|
||||
const config = JSON.parse(await readFile(paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(config.mcpServers[MCP_SERVER_NAME]).toEqual(foreignEntry);
|
||||
expect(existsSync(paths.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces its entry on reinstall without replacing the original backup or ownership', async () => {
|
||||
const env = await bootstrap({ mcpServers: { userServer: { command: 'user', args: [] } } });
|
||||
homes.push(env.home);
|
||||
const first = await install(installOpts(env));
|
||||
const firstPointer = JSON.parse(await readFile(first.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const replacementEntry = join(env.home, 'memory-mcp-v2.js');
|
||||
await writeFile(replacementEntry, 'export const version = 2;\n', 'utf-8');
|
||||
|
||||
const second = await install(installOpts(env, replacementEntry));
|
||||
const secondPointer = JSON.parse(await readFile(second.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const config = JSON.parse(await readFile(second.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, { args: string[] }>;
|
||||
};
|
||||
const backups = (await readdir(second.paths.claudeConfigDir))
|
||||
.filter((name) => name.includes('hive-mind-backup'));
|
||||
|
||||
expect(config.mcpServers[MCP_SERVER_NAME].args).toEqual([replacementEntry]);
|
||||
expect(Object.keys(config.mcpServers).filter((name) => name === MCP_SERVER_NAME)).toHaveLength(1);
|
||||
expect(second.backupPath).toBe(first.backupPath);
|
||||
expect(secondPointer['settings_backup']).toBe(firstPointer['settings_backup']);
|
||||
expect(secondPointer['created_by_us']).toBe(firstPointer['created_by_us']);
|
||||
expect(backups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when the MCP entry cannot be resolved', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
|
||||
await expect(install({
|
||||
home: env.home,
|
||||
platform: 'linux',
|
||||
mcpEntry: ' ',
|
||||
})).rejects.toThrow(/cannot resolve waggle-memory-mcp\/dist\/index\.js/);
|
||||
});
|
||||
});
|
||||
76
packages/hive-mind-hooks-claude-desktop/tests/paths.test.ts
Normal file
76
packages/hive-mind-hooks-claude-desktop/tests/paths.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
|
||||
describe('resolvePaths (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function tempHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-paths-'));
|
||||
homes.push(home);
|
||||
return home;
|
||||
}
|
||||
|
||||
it('uses the Windows Claude Desktop config path', async () => {
|
||||
const home = await tempHome();
|
||||
const paths = resolvePaths({ home, platform: 'win32' });
|
||||
expect(paths.configPath).toBe(join(
|
||||
home,
|
||||
'AppData',
|
||||
'Roaming',
|
||||
'Claude',
|
||||
'claude_desktop_config.json',
|
||||
));
|
||||
});
|
||||
|
||||
it('uses the macOS Claude Desktop config path', async () => {
|
||||
const home = await tempHome();
|
||||
const paths = resolvePaths({ home, platform: 'darwin' });
|
||||
expect(paths.configPath).toBe(join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Claude',
|
||||
'claude_desktop_config.json',
|
||||
));
|
||||
});
|
||||
|
||||
it('uses the Linux Claude Desktop config path', async () => {
|
||||
const home = await tempHome();
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
expect(paths.configPath).toBe(join(
|
||||
home,
|
||||
'.config',
|
||||
'Claude',
|
||||
'claude_desktop_config.json',
|
||||
));
|
||||
});
|
||||
|
||||
it('lets configDir override the platform default', async () => {
|
||||
const home = await tempHome();
|
||||
const configDir = join(home, 'custom-claude-config');
|
||||
const paths = resolvePaths({ home, platform: 'linux', configDir });
|
||||
expect(paths.claudeConfigDir).toBe(configDir);
|
||||
expect(paths.configPath).toBe(join(configDir, 'claude_desktop_config.json'));
|
||||
});
|
||||
|
||||
it('uses one platform-neutral home-relative pointer path', async () => {
|
||||
const home = await tempHome();
|
||||
for (const platform of ['win32', 'darwin', 'linux'] as const) {
|
||||
expect(resolvePaths({ home, platform }).pointerPath).toBe(join(
|
||||
home,
|
||||
'.waggle',
|
||||
'claude-desktop',
|
||||
'hive-mind-install.json',
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
112
packages/hive-mind-hooks-claude-desktop/tests/uninstall.test.ts
Normal file
112
packages/hive-mind-hooks-claude-desktop/tests/uninstall.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { install, MCP_SERVER_NAME } from '../src/install.js';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
import { uninstall } from '../src/uninstall.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
async function bootstrap(initial?: Record<string, unknown>): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-uninstall-'));
|
||||
const mcpEntry = join(home, 'memory-mcp.js');
|
||||
await writeFile(mcpEntry, 'export {};\n', 'utf-8');
|
||||
if (initial !== undefined) {
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await writeFile(paths.configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
return { home, mcpEntry };
|
||||
}
|
||||
|
||||
function installOpts(env: TestEnv) {
|
||||
return {
|
||||
home: env.home,
|
||||
platform: 'linux' as const,
|
||||
mcpEntry: env.mcpEntry,
|
||||
};
|
||||
}
|
||||
|
||||
describe('uninstall (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores an untouched config byte-identically and removes backup and pointer', async () => {
|
||||
const env = await bootstrap({
|
||||
mcpServers: { userServer: { command: 'python', args: ['server.py'] } },
|
||||
setting: true,
|
||||
});
|
||||
homes.push(env.home);
|
||||
const paths = resolvePaths({ home: env.home, platform: 'linux' });
|
||||
const before = await readFile(paths.configPath);
|
||||
const installed = await install(installOpts(env));
|
||||
|
||||
const result = await uninstall({ home: env.home, platform: 'linux' });
|
||||
const after = await readFile(paths.configPath);
|
||||
|
||||
expect(after).toEqual(before);
|
||||
expect(result.surgical).toBe(false);
|
||||
expect(result.backupRemoved).toBe(true);
|
||||
expect(result.createdRemoved).toBe(false);
|
||||
expect(existsSync(installed.backupPath as string)).toBe(false);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes a config created by the installer', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
const installed = await install(installOpts(env));
|
||||
|
||||
const result = await uninstall({ home: env.home, platform: 'linux' });
|
||||
|
||||
expect(result.createdRemoved).toBe(true);
|
||||
expect(result.restoredFrom).toBeNull();
|
||||
expect(result.surgical).toBe(false);
|
||||
expect(existsSync(installed.paths.configPath)).toBe(false);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('surgically removes only waggle-memory when the config changed after install', async () => {
|
||||
const originalServer = { command: 'python', args: ['original.py'] };
|
||||
const env = await bootstrap({ mcpServers: { originalServer } });
|
||||
homes.push(env.home);
|
||||
const installed = await install(installOpts(env));
|
||||
const edited = JSON.parse(await readFile(installed.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
const userAddedServer = { command: 'node', args: ['/user/added.js'] };
|
||||
edited.mcpServers['userAddedServer'] = userAddedServer;
|
||||
await writeFile(installed.paths.configPath, JSON.stringify(edited, null, 2) + '\n', 'utf-8');
|
||||
|
||||
const result = await uninstall({ home: env.home, platform: 'linux' });
|
||||
const after = JSON.parse(await readFile(installed.paths.configPath, 'utf-8')) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(result.surgical).toBe(true);
|
||||
expect(result.backupRemoved).toBe(false);
|
||||
expect(after.mcpServers[MCP_SERVER_NAME]).toBeUndefined();
|
||||
expect(after.mcpServers['originalServer']).toEqual(originalServer);
|
||||
expect(after.mcpServers['userAddedServer']).toEqual(userAddedServer);
|
||||
expect(existsSync(installed.backupPath as string)).toBe(true);
|
||||
expect(existsSync(installed.pointerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the install pointer is missing', async () => {
|
||||
const env = await bootstrap({});
|
||||
homes.push(env.home);
|
||||
|
||||
await expect(uninstall({ home: env.home, platform: 'linux' }))
|
||||
.rejects.toThrow(/no install pointer/);
|
||||
});
|
||||
});
|
||||
135
packages/hive-mind-hooks-claude-desktop/tests/verify.test.ts
Normal file
135
packages/hive-mind-hooks-claude-desktop/tests/verify.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { install, MCP_SERVER_NAME } from '../src/install.js';
|
||||
import { resolvePaths } from '../src/paths.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
|
||||
interface TestEnv {
|
||||
home: string;
|
||||
mcpEntry: string;
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<TestEnv> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-verify-'));
|
||||
const mcpEntry = join(home, 'memory-mcp.js');
|
||||
await writeFile(mcpEntry, 'export const ready = true;\n', 'utf-8');
|
||||
return { home, mcpEntry };
|
||||
}
|
||||
|
||||
async function writeConfig(home: string, config: Record<string, unknown>): Promise<void> {
|
||||
const paths = resolvePaths({ home, platform: 'linux' });
|
||||
await mkdir(paths.claudeConfigDir, { recursive: true });
|
||||
await writeFile(paths.configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function hangingSpawn(onSpawn: () => void): typeof import('node:child_process').spawn {
|
||||
return ((_command: string, _args: readonly string[], _options?: unknown) => {
|
||||
onSpawn();
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: vi.fn(() => true),
|
||||
}) as unknown as ChildProcess;
|
||||
return child;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
}
|
||||
|
||||
describe('verify (claude-desktop)', () => {
|
||||
const homes: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
for (const home of homes.splice(0)) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('passes with an installed config, readable entry, valid syntax, and pointer', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
await install({ home: env.home, platform: 'linux', mcpEntry: env.mcpEntry });
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.checks.map((check) => check.name)).toEqual([
|
||||
'claude_desktop_config.json exists',
|
||||
'config parses as JSON',
|
||||
"mcpServers contains 'waggle-memory' entry",
|
||||
'server entry points at waggle-memory-mcp',
|
||||
'memory-mcp entry readable on disk',
|
||||
'memory-mcp entry parses (node --check)',
|
||||
'install pointer present',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails the config existence check when the config is missing', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks).toEqual([{
|
||||
name: 'claude_desktop_config.json exists',
|
||||
ok: false,
|
||||
detail: resolvePaths({ home: env.home, platform: 'linux' }).configPath,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("fails the mcpServers contains 'waggle-memory' entry check when absent", async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
await writeConfig(env.home, { mcpServers: { other: { command: 'node', args: [] } } });
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
const check = result.checks.find((item) => item.name.includes("contains 'waggle-memory'"));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(check?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('fails the entry readability check when args[0] is missing on disk', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
const missingEntry = join(env.home, 'missing-memory-mcp.js');
|
||||
await writeConfig(env.home, {
|
||||
mcpServers: {
|
||||
[MCP_SERVER_NAME]: { command: process.execPath, args: [missingEntry] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await verify({ home: env.home, platform: 'linux' });
|
||||
const check = result.checks.find((item) => item.name === 'memory-mcp entry readable on disk');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(check?.ok).toBe(false);
|
||||
expect(check?.detail).toBe(missingEntry);
|
||||
});
|
||||
|
||||
it('times out and kills a hung node --check probe through spawnImpl', async () => {
|
||||
const env = await bootstrap();
|
||||
homes.push(env.home);
|
||||
await install({ home: env.home, platform: 'linux', mcpEntry: env.mcpEntry });
|
||||
vi.useFakeTimers();
|
||||
let spawned = false;
|
||||
const spawnImpl = hangingSpawn(() => { spawned = true; });
|
||||
|
||||
const resultPromise = verify({ home: env.home, platform: 'linux', spawnImpl });
|
||||
await vi.waitFor(() => expect(spawned).toBe(true));
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
const result = await resultPromise;
|
||||
const check = result.checks.find(
|
||||
(item) => item.name === 'memory-mcp entry parses (node --check)',
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(check?.ok).toBe(false);
|
||||
expect(check?.detail).toContain('timed out');
|
||||
});
|
||||
});
|
||||
15
packages/hive-mind-hooks-claude-desktop/tsconfig.json
Normal file
15
packages/hive-mind-hooks-claude-desktop/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../hive-mind-hooks-core" },
|
||||
{ "path": "../hive-mind-shim-core" }
|
||||
],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests/**", "dist/**", "node_modules/**"]
|
||||
}
|
||||
12
packages/hive-mind-hooks-claude-desktop/tsconfig.test.json
Normal file
12
packages/hive-mind-hooks-claude-desktop/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