diff --git a/package.json b/package.json index 5102faa..f370efa 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "bin": { - "agentmark-mcp": "./dist/src/mcp/cli.js" + "agentmark-mcp": "./dist/src/mcp/cli.js", + "agentmark": "./dist/src/mcp/cli.js" }, "license": "MIT", "repository": { diff --git a/src/mcp/cli.ts b/src/mcp/cli.ts index c143039..3daf77d 100644 --- a/src/mcp/cli.ts +++ b/src/mcp/cli.ts @@ -1,35 +1,220 @@ #!/usr/bin/env node /** - * `agentmark-mcp` CLI — the bin entry referenced by package.json. + * `agentmark-mcp` CLI. * - * Configure in any MCP client to expose the entire AgentMark library: - * - * { - * "mcpServers": { - * "agentmark": { - * "command": "npx", - * "args": ["-y", "@thinkfleet/agentmark", "agentmark-mcp"] - * } - * } - * } - * - * (Or just `npx -y @thinkfleet/agentmark` once the bin name resolves on $PATH.) + * agentmark-mcp → start the MCP server (default) + * agentmark-mcp serve → same; explicit + * agentmark-mcp install → auto-wire detected AI clients + * agentmark-mcp install --client=cursor --dry-run + * agentmark-mcp uninstall → remove our entries + * agentmark-mcp doctor → diagnose what's wired up + * agentmark-mcp --help → usage */ +import * as path from 'node:path' +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' import { startMcpServer } from './server' +import { + installToClients, + uninstallFromClients, + allClients, + type McpServerEntry, +} from './install' + +const HELP = `agentmark-mcp — Model Context Protocol server for AgentMark + +USAGE + agentmark-mcp [serve] Start the MCP server (default; speaks stdio) + agentmark-mcp install [options] Wire detected AI clients to talk to this server + agentmark-mcp setup [options] Alias for install (more discoverable name) + agentmark-mcp uninstall [options] Remove our entry from those clients + agentmark-mcp doctor Diagnose what's installed + wired up + agentmark-mcp --help This help + +QUICKSTART (Node already installed) + Run the npm package directly via npx, no global install required: + + npx -y @thinkfleet/agentmark setup + + Detects Claude Code / Claude Desktop / Cursor / Windsurf in one go + and wires them all up to use this MCP server. Use --dry-run first + to preview. + +OPTIONS (install / setup / uninstall) + --client= Target specific client(s). Repeat or comma-separate. + Known: ${allClients().map((c) => c.id).join(', ')} + --name= Entry name to register under (default: agentmark) + --command= Absolute command path to register (default: auto-detected) + --dry-run Show what would change without writing +` + +async function main(argv: string[]): Promise { + const sub = argv[0] ?? 'serve' + + if (sub === '-h' || sub === '--help' || sub === 'help') { + process.stdout.write(HELP) + return 0 + } -async function main(): Promise { - await startMcpServer({ - name: 'agentmark', - // Version is read from package.json at build time; for now hardcoded. - version: '0.7.0', + if (sub === 'serve') { + await startMcpServer({ name: 'agentmark', version: pkgVersion() }) + // The MCP transport keeps the event loop alive via stdio. + return await new Promise(() => { /* never resolves */ }) + } + + if (sub === 'install' || sub === 'setup' || sub === 'quickstart') { + return await runInstall(argv.slice(1)) + } + if (sub === 'uninstall') { + return await runUninstall(argv.slice(1)) + } + if (sub === 'doctor') { + return await runDoctor() + } + + process.stderr.write(`Unknown subcommand: ${sub}\n\n${HELP}`) + return 2 +} + +async function runInstall(args: string[]): Promise { + const flags = parseFlags(args) + const entry = buildEntryFromFlags(flags) + const result = await installToClients({ + clientIds: flags.client, + entry, + entryName: flags.name?.[0], + dryRun: flags.dryRun, }) - // Stay alive — the MCP transport keeps the event loop busy via stdio. + printInstallResult(result, flags.dryRun ? 'dry-run' : 'install') + return result.clients.every((r) => r.action !== 'error') ? 0 : 1 +} + +async function runUninstall(args: string[]): Promise { + const flags = parseFlags(args) + const result = await uninstallFromClients({ + clientIds: flags.client, + entryName: flags.name?.[0], + dryRun: flags.dryRun, + }) + printInstallResult(result, flags.dryRun ? 'dry-run' : 'uninstall') + return result.clients.every((r) => r.action !== 'error') ? 0 : 1 +} + +async function runDoctor(): Promise { + process.stdout.write('agentmark-mcp doctor\n\n') + process.stdout.write(`Auto-detected command: ${defaultCommand()}\n`) + process.stdout.write(`Node: ${process.execPath} (${process.version})\n\n`) + process.stdout.write('Clients:\n') + for (const c of allClients()) { + const cfg = c.configPath() ?? '(unsupported on this OS)' + const installed = await c.isInstalled() + process.stdout.write(` ${c.id.padEnd(16)} ${installed ? '✓' : '·'} ${cfg}\n`) + } + return 0 } -main().catch((err) => { - // eslint-disable-next-line no-console - console.error('Failed to start AgentMark MCP server:', err) - process.exit(1) -}) +interface ParsedFlags { + client: string[] | undefined + name: string[] | undefined + command: string[] | undefined + dryRun: boolean +} + +function parseFlags(args: string[]): ParsedFlags { + const client: string[] = [] + const name: string[] = [] + const command: string[] = [] + let dryRun = false + for (const arg of args) { + if (arg === '--dry-run' || arg === '-n') { dryRun = true; continue } + const m = arg.match(/^--(client|name|command)(?:=(.*))?$/) + if (!m) continue + const value = m[2] + if (value === undefined) continue + if (m[1] === 'client') value.split(',').filter(Boolean).forEach((v) => client.push(v.trim())) + if (m[1] === 'name') name.push(value) + if (m[1] === 'command') command.push(value) + } + return { + client: client.length > 0 ? client : undefined, + name: name.length > 0 ? name : undefined, + command: command.length > 0 ? command : undefined, + dryRun, + } +} + +function buildEntryFromFlags(flags: ParsedFlags): McpServerEntry { + const command = flags.command?.[0] ?? defaultCommand() + return { command, args: [] } +} + +/** + * Best-effort detection of the agentmark-mcp launcher to register in + * client configs. In order of preference: + * 1. Same-directory launcher script (when running from the bundled + * installer layout: /bin/agentmark-mcp or \agentmark-mcp.cmd) + * 2. `agentmark-mcp` on PATH + * 3. Fall back to `npx -y @thinkfleet/agentmark agentmark-mcp` form + */ +function defaultCommand(): string { + // 1. Bundled-installer layout: this script lives at + // /agentmark/dist/src/mcp/cli.js; the launcher is at + // /bin/agentmark-mcp (POSIX) or \agentmark-mcp.cmd (Win). + try { + const here = path.dirname(__filename) + const installRoot = path.resolve(here, '..', '..', '..', '..') + const posixLauncher = path.join(installRoot, 'bin', 'agentmark-mcp') + if (existsSync(posixLauncher)) return posixLauncher + const winLauncher = path.join(installRoot, 'agentmark-mcp.cmd') + if (existsSync(winLauncher)) return winLauncher + } catch { /* swallow — fall through to PATH lookup */ } + + // 2. PATH lookup. + const which = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['agentmark-mcp'], { encoding: 'utf8' }) + if (which.status === 0) { + const found = which.stdout.split(/\r?\n/).find((l) => l.trim().length > 0) + if (found) return found.trim() + } + + // 3. Last resort. + return process.execPath +} + +function pkgVersion(): string { + try { + // The compiled cli.js sits next to package.json in the dist tree + // when bundled by the installer; the source layout differs. + // Read it lazily and tolerate failure. + // eslint-disable-next-line @typescript-eslint/no-require-imports + return (require(path.resolve(__dirname, '..', '..', 'package.json')).version as string) ?? '0.0.0' + } catch { + return '0.0.0' + } +} + +function printInstallResult(result: { clients: Array<{ id: string; name: string; path: string; action: string; message?: string }> }, label: string): void { + process.stdout.write(`agentmark-mcp ${label} — results:\n\n`) + for (const c of result.clients) { + const flag = + c.action === 'added' || c.action === 'updated' || c.action === 'removed' ? '✓' + : c.action === 'already_present' || c.action === 'not_present' ? '·' + : c.action === 'skipped' ? '⏭' + : '✗' + const padded = `${c.name} [${c.action}]`.padEnd(40) + process.stdout.write(` ${flag} ${padded} ${c.path}\n`) + if (c.message) process.stdout.write(` ${c.message}\n`) + } + process.stdout.write('\n') +} + +main(process.argv.slice(2)) + .then((code) => { + if (typeof code === 'number' && code !== 0) process.exit(code) + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(`agentmark-mcp: ${(err as Error).message ?? err}`) + process.exit(1) + }) diff --git a/src/mcp/install/clients.ts b/src/mcp/install/clients.ts new file mode 100644 index 0000000..29d7e32 --- /dev/null +++ b/src/mcp/install/clients.ts @@ -0,0 +1,193 @@ +/** + * Per-client config descriptors. + * + * Every major MCP client uses the same `mcpServers` JSON shape; only + * the file path differs. The descriptors below capture the per-OS + * paths + a cheap "is this client installed" check so the installer + * can offer the right ones at runtime. + * + * Adding a new client: write one new factory function, append it to + * `ALL_CLIENTS`. + */ +import { access, constants } from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import type { ClientDescriptor, McpServerEntry } from './types' + +// The standard mcpServers shape every supported client uses today. +// Documented here so the per-client factories can share a single +// applyEntry / removeEntry implementation. +interface McpServersConfig { + mcpServers?: Record +} + +function applyStandardEntry(config: unknown, name: string, entry: McpServerEntry): unknown { + const cfg = (config && typeof config === 'object' ? config : {}) as McpServersConfig + const servers = { ...(cfg.mcpServers ?? {}) } + servers[name] = entry + return { ...cfg, mcpServers: servers } +} + +function removeStandardEntry(config: unknown, name: string): { config: unknown; removed: boolean } { + if (!config || typeof config !== 'object') return { config: config ?? {}, removed: false } + const cfg = config as McpServersConfig + if (!cfg.mcpServers || !(name in cfg.mcpServers)) return { config, removed: false } + const { [name]: _removed, ...rest } = cfg.mcpServers + void _removed + return { + config: { ...cfg, mcpServers: rest }, + removed: true, + } +} + +async function pathExists(p: string | null): Promise { + if (!p) return false + try { await access(p, constants.F_OK); return true } catch { return false } +} + +// ────────────────────────────────────────────────────────────────────── +// Claude Code CLI +// ────────────────────────────────────────────────────────────────────── + +function claudeCode(): ClientDescriptor { + return { + id: 'claude-code', + name: 'Claude Code', + configPath: () => path.join(os.homedir(), '.claude.json'), + isInstalled: async () => { + // Either the config file exists or `claude` is on PATH. + if (await pathExists(path.join(os.homedir(), '.claude.json'))) return true + return commandExists('claude') + }, + applyEntry: applyStandardEntry, + removeEntry: removeStandardEntry, + } +} + +// ────────────────────────────────────────────────────────────────────── +// Claude Desktop (standalone app) +// ────────────────────────────────────────────────────────────────────── + +function claudeDesktop(): ClientDescriptor { + return { + id: 'claude-desktop', + name: 'Claude Desktop', + configPath: () => { + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming') + return path.join(appData, 'Claude', 'claude_desktop_config.json') + } + // Linux: no official Claude Desktop, but the config layout is + // documented in case someone runs the unofficial port. + return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json') + }, + isInstalled: async () => { + const cfgPath = claudeDesktop().configPath() + if (await pathExists(cfgPath)) return true + if (process.platform === 'darwin') { + return pathExists('/Applications/Claude.app') + } + return false + }, + applyEntry: applyStandardEntry, + removeEntry: removeStandardEntry, + } +} + +// ────────────────────────────────────────────────────────────────────── +// Cursor +// ────────────────────────────────────────────────────────────────────── + +function cursor(): ClientDescriptor { + return { + id: 'cursor', + name: 'Cursor', + configPath: () => path.join(os.homedir(), '.cursor', 'mcp.json'), + isInstalled: async () => { + if (await pathExists(path.join(os.homedir(), '.cursor'))) return true + if (process.platform === 'darwin') { + return pathExists('/Applications/Cursor.app') + } + return commandExists('cursor') + }, + applyEntry: applyStandardEntry, + removeEntry: removeStandardEntry, + } +} + +// ────────────────────────────────────────────────────────────────────── +// Windsurf +// ────────────────────────────────────────────────────────────────────── + +function windsurf(): ClientDescriptor { + return { + id: 'windsurf', + name: 'Windsurf', + configPath: () => path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json'), + isInstalled: async () => { + if (await pathExists(path.join(os.homedir(), '.codeium', 'windsurf'))) return true + if (process.platform === 'darwin') { + return pathExists('/Applications/Windsurf.app') + } + return commandExists('windsurf') + }, + applyEntry: applyStandardEntry, + removeEntry: removeStandardEntry, + } +} + +// ────────────────────────────────────────────────────────────────────── +// Codex CLI (OpenAI) +// ────────────────────────────────────────────────────────────────────── + +function codex(): ClientDescriptor { + return { + id: 'codex', + name: 'Codex CLI', + configPath: () => path.join(os.homedir(), '.codex', 'config.toml'), + isInstalled: async () => { + if (await pathExists(path.join(os.homedir(), '.codex'))) return true + return commandExists('codex') + }, + // Codex CLI's config is TOML, not JSON, so it gets its own + // shape later. Until the TOML writer lands, the descriptor + // signals "not yet supported" via a sentinel error. + applyEntry: () => { + throw new Error( + 'Codex CLI configuration (TOML) is not yet supported by `agentmark-mcp install`. ' + + 'Add the agentmark MCP server manually to ~/.codex/config.toml for now.', + ) + }, + removeEntry: (config) => ({ config, removed: false }), + } +} + +// ────────────────────────────────────────────────────────────────────── +// Registry +// ────────────────────────────────────────────────────────────────────── + +export function allClients(): ClientDescriptor[] { + return [claudeCode(), claudeDesktop(), cursor(), windsurf(), codex()] +} + +export function clientById(id: string): ClientDescriptor | null { + return allClients().find((c) => c.id === id) ?? null +} + +/** Cheap PATH lookup for clients that ship a CLI binary. */ +async function commandExists(command: string): Promise { + const pathDirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean) + const exeSuffixes = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''] + for (const dir of pathDirs) { + for (const suffix of exeSuffixes) { + if (await pathExists(path.join(dir, command + suffix))) return true + } + } + return false +} + +// Re-export so consumers (tests) can poke at internals. +export { applyStandardEntry, removeStandardEntry, commandExists, pathExists } diff --git a/src/mcp/install/index.ts b/src/mcp/install/index.ts new file mode 100644 index 0000000..455c116 --- /dev/null +++ b/src/mcp/install/index.ts @@ -0,0 +1,172 @@ +/** + * `agentmark-mcp install` — auto-wire AI clients to talk to the agentmark + * MCP server. + * + * After the installer drops the binary onto a machine, this is the + * second-and-final click users need to be productive: detect which + * AI assistants are present (Claude Code, Claude Desktop, Cursor, + * Windsurf, …) and write the `mcpServers` entry into each one's + * config file. + * + * Idempotent: re-running picks up new clients without disturbing + * existing entries. Every write is preceded by a `.bak` copy + * so a fumbled config can be rolled back with one `mv`. + */ +import { allClients, clientById } from './clients' +import { readJson, writeJson } from './writer' +import type { + ClientDescriptor, + InstallOptions, + InstallResult, + McpServerEntry, + UninstallOptions, +} from './types' + +const DEFAULT_ENTRY_NAME = 'agentmark' + +/** + * Wire the agentmark MCP server into each detected (or caller-specified) + * AI client's config. Returns a per-client result so the CLI can print + * a readable summary without re-discovering state. + */ +export async function installToClients(options: InstallOptions): Promise { + const targets = await resolveTargets(options.clientIds) + const name = options.entryName ?? DEFAULT_ENTRY_NAME + const out: InstallResult['clients'] = [] + + for (const client of targets) { + const cfgPath = client.configPath() + if (!cfgPath) { + out.push({ + id: client.id, name: client.name, path: '', + action: 'skipped', + message: `Not supported on platform ${process.platform}.`, + }) + continue + } + + try { + const { value: current } = await readJson(cfgPath) + const existingEntry = extractExistingEntry(current, name) + const action: InstallResult['clients'][number]['action'] = existingEntry + ? entriesEqual(existingEntry, options.entry) ? 'already_present' : 'updated' + : 'added' + + const updated = client.applyEntry(current, name, options.entry) + + if (options.dryRun) { + out.push({ id: client.id, name: client.name, path: cfgPath, action, message: '(dry run; nothing written)' }) + continue + } + + const written = await writeJson(cfgPath, updated) + out.push({ + id: client.id, + name: client.name, + path: cfgPath, + action, + message: written.backup_path ? `backup: ${written.backup_path}` : undefined, + }) + } catch (err) { + out.push({ + id: client.id, name: client.name, path: cfgPath, + action: 'error', message: (err as Error).message, + }) + } + } + + return { clients: out } +} + +/** + * Remove agentmark from each targeted client's config. Mirror of + * installToClients with the same dry-run + error-collection semantics. + */ +export async function uninstallFromClients(options: UninstallOptions): Promise { + const targets = await resolveTargets(options.clientIds) + const name = options.entryName ?? DEFAULT_ENTRY_NAME + const out: InstallResult['clients'] = [] + + for (const client of targets) { + const cfgPath = client.configPath() + if (!cfgPath) { + out.push({ + id: client.id, name: client.name, path: '', + action: 'skipped', + message: `Not supported on platform ${process.platform}.`, + }) + continue + } + + try { + const { existed, value: current } = await readJson(cfgPath) + if (!existed) { + out.push({ id: client.id, name: client.name, path: cfgPath, action: 'not_present' }) + continue + } + const { config: updated, removed } = client.removeEntry(current, name) + if (!removed) { + out.push({ id: client.id, name: client.name, path: cfgPath, action: 'not_present' }) + continue + } + if (options.dryRun) { + out.push({ id: client.id, name: client.name, path: cfgPath, action: 'removed', message: '(dry run; nothing written)' }) + continue + } + const written = await writeJson(cfgPath, updated) + out.push({ + id: client.id, name: client.name, path: cfgPath, action: 'removed', + message: written.backup_path ? `backup: ${written.backup_path}` : undefined, + }) + } catch (err) { + out.push({ + id: client.id, name: client.name, path: cfgPath, + action: 'error', message: (err as Error).message, + }) + } + } + + return { clients: out } +} + +async function resolveTargets(ids?: string[]): Promise { + if (ids && ids.length > 0) { + const out: ClientDescriptor[] = [] + for (const id of ids) { + const c = clientById(id) + if (!c) throw new Error(`Unknown client id: ${id}. Known: ${allClients().map((x) => x.id).join(', ')}`) + out.push(c) + } + return out + } + // Default: every client that looks installed. + const all = allClients() + const detected: ClientDescriptor[] = [] + for (const c of all) { + if (await c.isInstalled()) detected.push(c) + } + return detected +} + +function extractExistingEntry(config: unknown, name: string): McpServerEntry | null { + if (!config || typeof config !== 'object') return null + const cfg = config as { mcpServers?: Record } + return cfg.mcpServers?.[name] ?? null +} + +function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean { + if (a.command !== b.command) return false + if (JSON.stringify(a.args ?? []) !== JSON.stringify(b.args ?? [])) return false + if (JSON.stringify(a.env ?? {}) !== JSON.stringify(b.env ?? {})) return false + return true +} + +export { allClients, clientById } from './clients' +export type { + ClientDescriptor, + InstallOptions, + UninstallOptions, + InstallResult, + McpServerEntry, +} from './types' +export { readJson, writeJson } from './writer' diff --git a/src/mcp/install/types.ts b/src/mcp/install/types.ts new file mode 100644 index 0000000..c2de4ff --- /dev/null +++ b/src/mcp/install/types.ts @@ -0,0 +1,74 @@ +/** + * Types for the `agentmark-mcp install` subcommand. + * + * Each supported MCP client (Claude Code, Claude Desktop, Cursor, + * Windsurf, Codex CLI, …) has a `ClientDescriptor` that describes: + * - how to detect whether the client is installed + * - where its MCP-config JSON file lives + * - whether its config uses the standard `mcpServers` shape or + * something bespoke + * + * The installer iterates descriptors, asks the user which to enable, + * and writes a uniform `McpServerEntry` into each chosen client's + * config file using atomic temp-file + rename. + */ + +export interface McpServerEntry { + /** Absolute path to the binary or launcher that runs the MCP server. */ + command: string + /** Arguments forwarded to the command. Typically empty. */ + args?: string[] + /** Environment variables to inject when the client spawns the server. */ + env?: Record +} + +export interface ClientDescriptor { + /** Stable identifier (`claude-code`, `cursor`, …). Lowercase, kebab-case. */ + id: string + /** Human-friendly display name for prompts + logs. */ + name: string + /** Returns the absolute path of the MCP-config file for this client on + * the current OS, or `null` when the client doesn't ship on this OS. */ + configPath(): string | null + /** Cheap detection — true when the config file already exists OR when + * the client's binary is on PATH. Used to decide whether to even + * offer this client in the interactive picker. */ + isInstalled(): Promise + /** Apply the new server entry to a parsed config object. Most clients + * use the standard `mcpServers` shape but the seam exists for any + * that diverge later. */ + applyEntry(config: unknown, name: string, entry: McpServerEntry): unknown + /** Remove our entry from a parsed config object. Returns whether + * anything was removed (useful for `--remove` reporting). */ + removeEntry(config: unknown, name: string): { config: unknown; removed: boolean } +} + +export interface InstallResult { + /** One entry per client we attempted to wire up. */ + clients: Array<{ + id: string + name: string + path: string + action: 'added' | 'updated' | 'already_present' | 'removed' | 'not_present' | 'skipped' | 'error' + message?: string + }> +} + +export interface InstallOptions { + /** Restrict to specific client ids. When omitted, all detected + * clients are targeted. */ + clientIds?: string[] + /** Don't write anything; just report what would change. */ + dryRun?: boolean + /** The server entry to install / update. */ + entry: McpServerEntry + /** Name to register under in each client's `mcpServers` map. + * Default: 'agentmark'. */ + entryName?: string +} + +export interface UninstallOptions { + clientIds?: string[] + dryRun?: boolean + entryName?: string +} diff --git a/src/mcp/install/writer.ts b/src/mcp/install/writer.ts new file mode 100644 index 0000000..df944f7 --- /dev/null +++ b/src/mcp/install/writer.ts @@ -0,0 +1,83 @@ +/** + * Atomic JSON writer for client config files. + * + * Every write goes through: + * 1. Read existing file (or empty object if missing). + * 2. Back up to `.bak` (rotated — only one backup kept). + * 3. Write new content to `.tmp--`. + * 4. Rename onto the target path. + * + * Rename is atomic on every Unix filesystem we care about and on + * Windows NTFS when MoveFileEx is used (Node's `fs.rename` does this + * automatically). The .bak makes recovery one `mv` away if anything + * goes wrong. + */ +import { readFile, writeFile, rename, mkdir, copyFile } from 'node:fs/promises' +import * as path from 'node:path' + +export interface WriteResult { + /** Did the file exist before this write? */ + existed_before: boolean + /** Path of the .bak file we created (when the file existed). */ + backup_path?: string + /** Bytes written. */ + bytes: number +} + +/** + * Read + parse a JSON file. Returns `{}` when the file doesn't exist. + * Throws on malformed JSON so callers can decide whether to bail or + * overwrite (we choose to bail — losing a user's config silently is + * not OK). + */ +export async function readJson(filePath: string): Promise<{ existed: boolean; value: unknown }> { + try { + const raw = await readFile(filePath, 'utf8') + try { + return { existed: true, value: JSON.parse(raw) } + } catch (err) { + throw new Error( + `${filePath} exists but is not valid JSON: ${(err as Error).message}. ` + + `Refusing to overwrite. Fix the file or remove it and re-run.`, + ) + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { existed: false, value: {} } + } + throw err + } +} + +export async function writeJson(filePath: string, value: unknown): Promise { + const dir = path.dirname(filePath) + await mkdir(dir, { recursive: true }) + + const existedBefore = await fileExists(filePath) + let backupPath: string | undefined + if (existedBefore) { + backupPath = `${filePath}.bak` + await copyFile(filePath, backupPath) + } + + const json = JSON.stringify(value, null, 2) + '\n' + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}` + await writeFile(tmp, json, 'utf8') + await rename(tmp, filePath) + + return { + existed_before: existedBefore, + backup_path: backupPath, + bytes: Buffer.byteLength(json, 'utf8'), + } +} + +async function fileExists(p: string): Promise { + try { + const { access, constants } = await import('node:fs/promises') + await access(p, constants.F_OK) + return true + } catch { + return false + } +} diff --git a/test/mcp/install.test.ts b/test/mcp/install.test.ts new file mode 100644 index 0000000..b2f4769 --- /dev/null +++ b/test/mcp/install.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for `agentmark-mcp install` — the auto-wiring of detected AI + * clients (Claude Code, Claude Desktop, Cursor, Windsurf) to talk to + * the agentmark MCP server. + * + * Tests run against synthetic config files in a tmpdir. The client + * descriptors are exercised by injecting a custom one that points at + * the tmp paths — keeps the test independent of whether Claude Code + * et al. are actually installed on the test machine. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, rm, writeFile, readFile, access, constants } from 'node:fs/promises' +import { + installToClients, + uninstallFromClients, + readJson, + writeJson, + type ClientDescriptor, + type McpServerEntry, +} from '../../src/mcp/install' + +let tmp: string + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'agentmark-install-')) +}) + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }) +}) + +/** Build a synthetic descriptor pointing at a tmp file so tests don't + * depend on which clients are installed on the host. */ +function fakeClient(id: string, fileName: string): ClientDescriptor { + const cfgPath = path.join(tmp, fileName) + return { + id, + name: id, + configPath: () => cfgPath, + isInstalled: async () => true, + applyEntry: (config, name, entry) => { + const cfg = (config && typeof config === 'object' ? config : {}) as { mcpServers?: Record } + return { ...cfg, mcpServers: { ...(cfg.mcpServers ?? {}), [name]: entry } } + }, + removeEntry: (config, name) => { + if (!config || typeof config !== 'object') return { config: {}, removed: false } + const cfg = config as { mcpServers?: Record } + if (!cfg.mcpServers || !(name in cfg.mcpServers)) return { config, removed: false } + const { [name]: _, ...rest } = cfg.mcpServers + void _ + return { config: { ...cfg, mcpServers: rest }, removed: true } + }, + } +} + +const ENTRY: McpServerEntry = { command: '/opt/thinkfleet/agentmark/bin/agentmark-mcp', args: [] } + +// installToClients takes ids and looks them up in the global registry, +// but tests want to use fake descriptors. Drive the underlying logic by +// calling apply + writeJson directly when we need to drive a synthetic +// client; use the real installToClients flow when we want end-to-end +// coverage via the global registry. +// +// For the unit tests below we use the underlying readJson/writeJson + +// descriptor methods directly (this is what installToClients does +// internally). +async function applyToFake(client: ClientDescriptor, entry: McpServerEntry, name = 'agentmark'): Promise<{ action: string; backup?: string }> { + const cfgPath = client.configPath()! + const { value: current } = await readJson(cfgPath) + const existing = (current as { mcpServers?: Record })?.mcpServers?.[name] + const action = existing + ? JSON.stringify(existing) === JSON.stringify(entry) ? 'already_present' : 'updated' + : 'added' + const updated = client.applyEntry(current, name, entry) + const result = await writeJson(cfgPath, updated) + return { action, backup: result.backup_path } +} + +describe('readJson / writeJson — atomic config IO', () => { + it('returns existed=false + value={} for a missing file', async () => { + const r = await readJson(path.join(tmp, 'missing.json')) + expect(r.existed).toBe(false) + expect(r.value).toEqual({}) + }) + + it('round-trips JSON values', async () => { + const file = path.join(tmp, 'cfg.json') + const w = await writeJson(file, { mcpServers: { x: { command: '/x' } } }) + expect(w.existed_before).toBe(false) + expect(w.backup_path).toBeUndefined() + + const r = await readJson(file) + expect(r.existed).toBe(true) + expect(r.value).toEqual({ mcpServers: { x: { command: '/x' } } }) + }) + + it('creates a .bak file on second write', async () => { + const file = path.join(tmp, 'cfg.json') + await writeJson(file, { v: 1 }) + const w = await writeJson(file, { v: 2 }) + expect(w.existed_before).toBe(true) + expect(w.backup_path).toBe(file + '.bak') + await access(file + '.bak', constants.F_OK) // doesn't throw + }) + + it('refuses to overwrite a file with malformed JSON', async () => { + const file = path.join(tmp, 'broken.json') + await writeFile(file, '{not valid json') + await expect(readJson(file)).rejects.toThrow(/not valid JSON/) + }) + + it('creates parent directories when needed', async () => { + const file = path.join(tmp, 'a', 'b', 'c.json') + await writeJson(file, { hi: true }) + const r = await readJson(file) + expect(r.value).toEqual({ hi: true }) + }) +}) + +describe('Fake client end-to-end — apply + remove', () => { + it('adds the entry under mcpServers on first run', async () => { + const client = fakeClient('test-a', 'a.json') + const r = await applyToFake(client, ENTRY) + expect(r.action).toBe('added') + + const written = JSON.parse(await readFile(client.configPath()!, 'utf8')) + expect(written).toEqual({ mcpServers: { agentmark: ENTRY } }) + }) + + it('preserves unrelated keys (mcp + other) in the config', async () => { + const client = fakeClient('test-b', 'b.json') + await writeJson(client.configPath()!, { + theme: 'dark', + mcpServers: { existing: { command: '/other' } }, + }) + await applyToFake(client, ENTRY) + const written = JSON.parse(await readFile(client.configPath()!, 'utf8')) + expect(written).toEqual({ + theme: 'dark', + mcpServers: { + existing: { command: '/other' }, + agentmark: ENTRY, + }, + }) + }) + + it('reports already_present when re-running with the same entry', async () => { + const client = fakeClient('test-c', 'c.json') + await applyToFake(client, ENTRY) + const second = await applyToFake(client, ENTRY) + expect(second.action).toBe('already_present') + }) + + it('reports updated when the entry exists but differs', async () => { + const client = fakeClient('test-d', 'd.json') + await applyToFake(client, ENTRY) + const next = await applyToFake(client, { command: '/different/path' }) + expect(next.action).toBe('updated') + }) + + it('removes only our entry, leaving siblings intact', async () => { + const client = fakeClient('test-e', 'e.json') + await writeJson(client.configPath()!, { + mcpServers: { agentmark: ENTRY, sibling: { command: '/s' } }, + }) + const { config: updated, removed } = client.removeEntry( + JSON.parse(await readFile(client.configPath()!, 'utf8')), + 'agentmark', + ) + expect(removed).toBe(true) + expect(updated).toEqual({ mcpServers: { sibling: { command: '/s' } } }) + }) + + it('removeEntry returns removed=false when no agentmark entry exists', async () => { + const client = fakeClient('test-f', 'f.json') + await writeJson(client.configPath()!, { mcpServers: { other: { command: '/o' } } }) + const { removed } = client.removeEntry( + JSON.parse(await readFile(client.configPath()!, 'utf8')), + 'agentmark', + ) + expect(removed).toBe(false) + }) +}) + +describe('installToClients / uninstallFromClients via the global registry', () => { + // These tests exercise the actual installToClients() flow. We + // restrict to specific client ids and point those clients at tmp + // paths via env-var injection isn't possible (descriptors are + // static), so we instead use --dry-run mode + clientIds for a + // client we know is in the registry. The combination proves the + // flow without requiring the test machine to have any clients + // installed. + + it('errors on unknown client id', async () => { + await expect( + installToClients({ clientIds: ['nonexistent'], entry: ENTRY }), + ).rejects.toThrow(/Unknown client id/) + }) + + it('dry-run install does NOT write to disk', async () => { + // Use a real client id but point its config at a path we control + // — actually impossible with static descriptors. So we drive a + // representative scenario through the fake-client unit tests + // above, and just confirm dry-run produces the right action + // tag here via the public API. + const result = await installToClients({ + clientIds: ['claude-code'], + entry: ENTRY, + dryRun: true, + }) + expect(result.clients).toHaveLength(1) + expect(['added', 'updated', 'already_present']).toContain(result.clients[0].action) + // dry-run message is appended. + expect(result.clients[0].message ?? '').toMatch(/dry run/i) + }) +})