moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -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();

View 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';

View 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,
};
}

View 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;
}

View 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,
};
}

View 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 };
}