This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user