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,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

View 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.

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

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

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

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

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

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

View 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/**"]
}

View 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/**"]
}