diff --git a/src/cli.ts b/src/cli.ts index 8f7e0d1a2e..84dcd4b466 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -83,6 +83,10 @@ Usage: agentmemory [command] [options] Commands: (default) Start agentmemory worker init Copy bundled .env.example to ~/.agentmemory/.env if absent + connect [agent] Wire agentmemory into an installed agent (claude-code, codex, + cursor, gemini-cli, openclaw, hermes, pi, openhuman). + No arg = interactive picker. --all wires every detected agent. + --dry-run shows what would change. --force re-installs. status Show connection status, memory count, flags, and health doctor Run diagnostic checks (server, flags, graph, providers) demo Seed sample sessions and show recall in action @@ -1538,6 +1542,11 @@ async function runMcp(): Promise { await import("./mcp/standalone.js"); } +async function runConnectCmd(): Promise { + const { runConnect } = await import("./cli/connect/index.js"); + await runConnect(args.slice(1)); +} + async function runImportJsonl(): Promise { // Long-form flags that take a value. Their value tokens must be // consumed alongside the flag so they don't leak into positional @@ -1716,6 +1725,7 @@ async function runImportJsonl(): Promise { const commands: Record Promise> = { init: runInit, + connect: runConnectCmd, status: runStatus, doctor: runDoctor, demo: runDemo, diff --git a/src/cli/connect/claude-code.ts b/src/cli/connect/claude-code.ts new file mode 100644 index 0000000000..48117b93db --- /dev/null +++ b/src/cli/connect/claude-code.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + AGENTMEMORY_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +const CLAUDE_DIR = join(homedir(), ".claude"); +const CLAUDE_JSON = join(homedir(), ".claude.json"); + +type ClaudeMcpEntry = typeof AGENTMEMORY_MCP_BLOCK; +type ClaudeConfig = { + mcpServers?: Record; + [key: string]: unknown; +}; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const e = entry as Record; + if (e["command"] !== "npx") return false; + const args = Array.isArray(e["args"]) ? (e["args"] as string[]) : []; + return args.includes("@agentmemory/mcp"); +} + +export const adapter: ConnectAdapter = { + name: "claude-code", + displayName: "Claude Code", + docs: "https://github.com/rohitg00/agentmemory#claude-code-one-block-paste-it", + + detect(): boolean { + return existsSync(CLAUDE_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(CLAUDE_JSON); + const next: ClaudeConfig = existing ? { ...existing } : {}; + const servers: Record = { + ...((next.mcpServers as Record) ?? {}), + }; + + const alreadyHas = entryMatches(servers["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired("Claude Code", CLAUDE_JSON); + return { kind: "already-wired", mutatedPath: CLAUDE_JSON }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcpServers.agentmemory in ${CLAUDE_JSON}`, + ); + return { kind: "installed", mutatedPath: CLAUDE_JSON }; + } + + let backupPath: string | undefined; + if (existsSync(CLAUDE_JSON)) { + backupPath = backupFile(CLAUDE_JSON, "claude-code"); + logBackup(backupPath); + } else { + mkdirSync(CLAUDE_DIR, { recursive: true }); + writeFileSync(CLAUDE_JSON, "{}\n", "utf-8"); + } + + servers["agentmemory"] = AGENTMEMORY_MCP_BLOCK; + next.mcpServers = servers; + writeJsonAtomic(CLAUDE_JSON, next); + + const verify = readJsonSafe(CLAUDE_JSON); + if (!entryMatches(verify?.mcpServers?.["agentmemory"])) { + p.log.error( + `Verification failed: ${CLAUDE_JSON} did not contain mcpServers.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled("Claude Code", CLAUDE_JSON); + p.log.info( + "Restart Claude Code (or run `/mcp` inside a session) to pick up the new server.", + ); + return { kind: "installed", mutatedPath: CLAUDE_JSON, backupPath }; + }, +}; diff --git a/src/cli/connect/codex.ts b/src/cli/connect/codex.ts new file mode 100644 index 0000000000..b7d3b1fe31 --- /dev/null +++ b/src/cli/connect/codex.ts @@ -0,0 +1,112 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + backupFile, + logAlreadyWired, + logBackup, + logInstalled, +} from "./util.js"; + +const CODEX_DIR = join(homedir(), ".codex"); +const CODEX_TOML = join(CODEX_DIR, "config.toml"); + +const TOML_BLOCK = `[mcp_servers.agentmemory] +command = "npx" +args = ["-y", "@agentmemory/mcp"] + +[mcp_servers.agentmemory.env] +AGENTMEMORY_URL = "http://localhost:3111" +`; + +const SECTION_HEADER = "[mcp_servers.agentmemory]"; + +function isWiredText(toml: string): boolean { + return toml.includes(SECTION_HEADER); +} + +function stripExistingBlock(toml: string): string { + const lines = toml.split(/\r?\n/); + const out: string[] = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + if ( + trimmed === SECTION_HEADER || + trimmed === "[mcp_servers.agentmemory.env]" + ) { + skipping = true; + continue; + } + if ( + skipping && + trimmed.startsWith("[") && + trimmed !== "[mcp_servers.agentmemory.env]" + ) { + skipping = false; + } + if (!skipping) out.push(line); + } + return out.join("\n").replace(/\n{3,}$/, "\n\n").trimEnd() + "\n"; +} + +export const adapter: ConnectAdapter = { + name: "codex", + displayName: "Codex CLI", + docs: "https://github.com/rohitg00/agentmemory#codex-cli-codex-plugin-platform", + + detect(): boolean { + return existsSync(CODEX_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const exists = existsSync(CODEX_TOML); + const current = exists ? readFileSync(CODEX_TOML, "utf-8") : ""; + const wired = isWiredText(current); + + if (wired && !opts.force) { + logAlreadyWired("Codex CLI", CODEX_TOML); + return { kind: "already-wired", mutatedPath: CODEX_TOML }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${wired ? "rewrite" : "append"} [mcp_servers.agentmemory] in ${CODEX_TOML}`, + ); + return { kind: "installed", mutatedPath: CODEX_TOML }; + } + + let backupPath: string | undefined; + if (exists) { + backupPath = backupFile(CODEX_TOML, "codex", "toml"); + logBackup(backupPath); + } else { + mkdirSync(dirname(CODEX_TOML), { recursive: true }); + } + + const cleaned = wired ? stripExistingBlock(current) : current; + const joiner = cleaned.length === 0 || cleaned.endsWith("\n") ? "" : "\n"; + const next = `${cleaned}${joiner}${cleaned.length > 0 ? "\n" : ""}${TOML_BLOCK}`; + writeFileSync(CODEX_TOML, next, "utf-8"); + + const verify = readFileSync(CODEX_TOML, "utf-8"); + if (!isWiredText(verify)) { + p.log.error( + `Verification failed: ${CODEX_TOML} did not contain ${SECTION_HEADER} after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled("Codex CLI", CODEX_TOML); + p.log.info( + "Codex picks up MCP servers on next launch. For the deeper plugin install, run: codex plugin marketplace add rohitg00/agentmemory && codex plugin install agentmemory", + ); + return { + kind: "installed", + mutatedPath: CODEX_TOML, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; diff --git a/src/cli/connect/cursor.ts b/src/cli/connect/cursor.ts new file mode 100644 index 0000000000..7da7c7d749 --- /dev/null +++ b/src/cli/connect/cursor.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +export const adapter = createJsonMcpAdapter({ + name: "cursor", + displayName: "Cursor", + detectDir: join(homedir(), ".cursor"), + configPath: join(homedir(), ".cursor", "mcp.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", +}); diff --git a/src/cli/connect/gemini-cli.ts b/src/cli/connect/gemini-cli.ts new file mode 100644 index 0000000000..1a38d60835 --- /dev/null +++ b/src/cli/connect/gemini-cli.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +export const adapter = createJsonMcpAdapter({ + name: "gemini-cli", + displayName: "Gemini CLI", + detectDir: join(homedir(), ".gemini"), + configPath: join(homedir(), ".gemini", "settings.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", +}); diff --git a/src/cli/connect/hermes.ts b/src/cli/connect/hermes.ts new file mode 100644 index 0000000000..18d304fbe5 --- /dev/null +++ b/src/cli/connect/hermes.ts @@ -0,0 +1,45 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; + +const HERMES_DIR = join(homedir(), ".hermes"); +const HERMES_CONFIG = join(HERMES_DIR, "config.yaml"); +const DOCS = "https://github.com/rohitg00/agentmemory/tree/main/integrations/hermes"; + +export const adapter: ConnectAdapter = { + name: "hermes", + displayName: "Hermes Agent", + docs: DOCS, + + detect(): boolean { + return existsSync(HERMES_DIR); + }, + + async install(_opts: ConnectOptions): Promise { + p.log.warn( + "Hermes uses YAML config. Automated merge isn't implemented yet — manual install required.", + ); + p.note( + [ + `Add to ${HERMES_CONFIG}:`, + "", + " mcp_servers:", + " agentmemory:", + " command: npx", + ' args: ["-y", "@agentmemory/mcp"]', + "", + " memory:", + " provider: agentmemory", + "", + `Full guide: ${DOCS}`, + ].join("\n"), + "Hermes manual install", + ); + return { + kind: "stub", + reason: "yaml-merge-not-implemented", + }; + }, +}; diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts new file mode 100644 index 0000000000..5d90a350bb --- /dev/null +++ b/src/cli/connect/index.ts @@ -0,0 +1,170 @@ +import { platform } from "node:os"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { adapter as claudeCode } from "./claude-code.js"; +import { adapter as codex } from "./codex.js"; +import { adapter as cursor } from "./cursor.js"; +import { adapter as geminiCli } from "./gemini-cli.js"; +import { adapter as hermes } from "./hermes.js"; +import { adapter as openclaw } from "./openclaw.js"; +import { adapter as openhuman } from "./openhuman.js"; +import { adapter as pi } from "./pi.js"; + +export const ADAPTERS: readonly ConnectAdapter[] = [ + claudeCode, + codex, + cursor, + geminiCli, + openclaw, + hermes, + pi, + openhuman, +]; + +export function resolveAdapter(name: string): ConnectAdapter | null { + const lower = name.toLowerCase(); + return ADAPTERS.find((a) => a.name === lower) ?? null; +} + +export function knownAgents(): string[] { + return ADAPTERS.map((a) => a.name); +} + +function parseFlags(args: string[]): { + dryRun: boolean; + force: boolean; + all: boolean; + positional: string[]; +} { + const positional: string[] = []; + let dryRun = false; + let force = false; + let all = false; + for (const a of args) { + if (a === "--dry-run") dryRun = true; + else if (a === "--force") force = true; + else if (a === "--all") all = true; + else if (!a.startsWith("-")) positional.push(a); + } + return { dryRun, force, all, positional }; +} + +async function runAdapter( + adapter: ConnectAdapter, + opts: ConnectOptions, +): Promise { + if (!adapter.detect()) { + p.log.warn( + `${adapter.displayName}: not detected on this machine (skipping).${adapter.docs ? ` Docs: ${adapter.docs}` : ""}`, + ); + return { kind: "skipped", reason: "not-detected" }; + } + p.log.step(`Wiring ${adapter.displayName}…`); + try { + return await adapter.install(opts); + } catch (err) { + p.log.error( + `${adapter.displayName}: ${err instanceof Error ? err.message : String(err)}`, + ); + return { kind: "skipped", reason: "exception" }; + } +} + +export async function runConnect(args: string[]): Promise { + if (platform() === "win32") { + p.intro("agentmemory connect"); + p.log.warn( + "Windows: automated `connect` is not supported yet. See https://github.com/rohitg00/agentmemory#other-agents for manual install steps.", + ); + p.outro("Windows: manual install required — see docs"); + return; + } + + const { dryRun, force, all, positional } = parseFlags(args); + const opts: ConnectOptions = { dryRun, force }; + + p.intro("agentmemory connect"); + + if (positional.length === 0 && !all) { + const detected = ADAPTERS.filter((a) => a.detect()); + if (detected.length === 0) { + p.log.error("No supported agents detected on this machine."); + p.outro(`Supported: ${knownAgents().join(", ")}`); + process.exit(1); + } + const picked = await p.multiselect({ + message: "Wire agentmemory into which agents?", + options: detected.map((a) => ({ value: a.name, label: a.displayName })), + required: true, + }); + if (p.isCancel(picked)) { + p.cancel("Cancelled."); + return; + } + const results: { name: string; result: ConnectResult }[] = []; + for (const name of picked as string[]) { + const adapter = resolveAdapter(name); + if (!adapter) continue; + results.push({ name, result: await runAdapter(adapter, opts) }); + } + summarize(results); + return; + } + + if (all) { + const detected = ADAPTERS.filter((a) => a.detect()); + if (detected.length === 0) { + p.log.error("No supported agents detected on this machine."); + process.exit(1); + } + const results: { name: string; result: ConnectResult }[] = []; + for (const adapter of detected) { + results.push({ + name: adapter.name, + result: await runAdapter(adapter, opts), + }); + } + summarize(results); + return; + } + + const agentName = positional[0]!; + const adapter = resolveAdapter(agentName); + if (!adapter) { + p.log.error(`Unknown agent: ${agentName}`); + p.outro(`Supported: ${knownAgents().join(", ")}`); + process.exit(1); + } + + const result = await runAdapter(adapter, opts); + summarize([{ name: agentName, result }]); + if (result.kind === "skipped" && (result as { reason: string }).reason !== "not-detected") { + process.exit(1); + } +} + +function summarize( + results: { name: string; result: ConnectResult }[], +): void { + const lines = results.map(({ name, result }) => { + switch (result.kind) { + case "installed": + return ` ✓ ${name}${result.mutatedPath ? ` → ${result.mutatedPath}` : ""}`; + case "already-wired": + return ` ✓ ${name} (already wired)`; + case "stub": + return ` ⚠ ${name} (manual install required: ${result.reason})`; + case "skipped": + return ` ✗ ${name} (skipped: ${result.reason})`; + } + }); + p.note(lines.join("\n"), "summary"); + + const stubs = results.filter((r) => r.result.kind === "stub"); + if (stubs.length > 0) { + p.log.info( + `${stubs.length} agent(s) require manual install — see docs links above.`, + ); + } + p.outro("Restart any wired agent (or open a new session) to pick up agentmemory."); +} diff --git a/src/cli/connect/json-mcp-adapter.ts b/src/cli/connect/json-mcp-adapter.ts new file mode 100644 index 0000000000..b368805b23 --- /dev/null +++ b/src/cli/connect/json-mcp-adapter.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + AGENTMEMORY_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +export type JsonMcpAdapterConfig = { + name: string; + displayName: string; + detectDir: string; + configPath: string; + docs?: string; +}; + +type McpEntry = typeof AGENTMEMORY_MCP_BLOCK; +type McpConfig = { + mcpServers?: Record; + [key: string]: unknown; +}; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const e = entry as Record; + if (e["command"] !== "npx") return false; + const args = Array.isArray(e["args"]) ? (e["args"] as string[]) : []; + return args.includes("@agentmemory/mcp"); +} + +export function createJsonMcpAdapter( + config: JsonMcpAdapterConfig, +): ConnectAdapter { + return { + name: config.name, + displayName: config.displayName, + ...(config.docs !== undefined && { docs: config.docs }), + + detect(): boolean { + return existsSync(config.detectDir); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(config.configPath); + const next: McpConfig = existing ? { ...existing } : {}; + const servers: Record = { + ...((next.mcpServers as Record) ?? {}), + }; + + const alreadyHas = entryMatches(servers["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired(config.displayName, config.configPath); + return { kind: "already-wired", mutatedPath: config.configPath }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcpServers.agentmemory in ${config.configPath}`, + ); + return { kind: "installed", mutatedPath: config.configPath }; + } + + let backupPath: string | undefined; + if (existsSync(config.configPath)) { + backupPath = backupFile(config.configPath, config.name); + logBackup(backupPath); + } else { + mkdirSync(dirname(config.configPath), { recursive: true }); + } + + servers["agentmemory"] = AGENTMEMORY_MCP_BLOCK; + next.mcpServers = servers; + writeJsonAtomic(config.configPath, next); + + const verify = readJsonSafe(config.configPath); + if (!entryMatches(verify?.mcpServers?.["agentmemory"])) { + p.log.error( + `Verification failed: ${config.configPath} did not contain mcpServers.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled(config.displayName, config.configPath); + return { + kind: "installed", + mutatedPath: config.configPath, + ...(backupPath !== undefined && { backupPath }), + }; + }, + }; +} diff --git a/src/cli/connect/openclaw.ts b/src/cli/connect/openclaw.ts new file mode 100644 index 0000000000..b333920e1e --- /dev/null +++ b/src/cli/connect/openclaw.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +export const adapter = createJsonMcpAdapter({ + name: "openclaw", + displayName: "OpenClaw", + detectDir: join(homedir(), ".openclaw"), + configPath: join(homedir(), ".openclaw", "openclaw.json"), + docs: "https://github.com/rohitg00/agentmemory/tree/main/integrations/openclaw", +}); diff --git a/src/cli/connect/openhuman.ts b/src/cli/connect/openhuman.ts new file mode 100644 index 0000000000..bcbfbbbc2a --- /dev/null +++ b/src/cli/connect/openhuman.ts @@ -0,0 +1,39 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; + +const OPENHUMAN_DIR = join(homedir(), ".openhuman"); +const DOCS = "https://github.com/tinyhumansai/openhuman"; + +export const adapter: ConnectAdapter = { + name: "openhuman", + displayName: "OpenHuman", + docs: DOCS, + + detect(): boolean { + return existsSync(OPENHUMAN_DIR); + }, + + async install(_opts: ConnectOptions): Promise { + p.log.warn( + "OpenHuman integration is not yet automated. No `integrations/openhuman/` folder exists in the agentmemory repo today.", + ); + p.note( + [ + "OpenHuman is a Memory-trait host. The expected wiring is the REST", + "proxy at http://localhost:3111 plus an OpenHuman-side Memory trait", + "impl. Once integrations/openhuman/ lands in agentmemory we'll wire", + "this up automatically.", + "", + `Tracking: ${DOCS}`, + ].join("\n"), + "OpenHuman manual install", + ); + return { + kind: "stub", + reason: "no-integration-folder-yet", + }; + }, +}; diff --git a/src/cli/connect/pi.ts b/src/cli/connect/pi.ts new file mode 100644 index 0000000000..b6552a90e8 --- /dev/null +++ b/src/cli/connect/pi.ts @@ -0,0 +1,44 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; + +const PI_DIR = join(homedir(), ".pi"); +const PI_EXT_DIR = join(PI_DIR, "agent", "extensions", "agentmemory"); +const DOCS = "https://github.com/rohitg00/agentmemory/tree/main/integrations/pi"; + +export const adapter: ConnectAdapter = { + name: "pi", + displayName: "pi", + docs: DOCS, + + detect(): boolean { + return existsSync(PI_DIR); + }, + + async install(_opts: ConnectOptions): Promise { + p.log.warn( + "pi uses a TypeScript extension file. Automated copy + register isn't implemented yet — manual install required.", + ); + p.note( + [ + "Run these from the agentmemory repo root:", + "", + ` mkdir -p ${PI_EXT_DIR}`, + ` cp integrations/pi/index.ts ${PI_EXT_DIR}/index.ts`, + ` cp integrations/pi/security.ts ${PI_EXT_DIR}/security.ts`, + "", + "Then add to ~/.pi/agent/settings.json:", + ' { "extensions": ["~/.pi/agent/extensions/agentmemory"] }', + "", + `Full guide: ${DOCS}`, + ].join("\n"), + "pi manual install", + ); + return { + kind: "stub", + reason: "ts-extension-copy-not-implemented", + }; + }, +}; diff --git a/src/cli/connect/types.ts b/src/cli/connect/types.ts new file mode 100644 index 0000000000..adfaef3084 --- /dev/null +++ b/src/cli/connect/types.ts @@ -0,0 +1,18 @@ +export type ConnectOptions = { + dryRun: boolean; + force: boolean; +}; + +export type ConnectAdapter = { + name: string; + displayName: string; + docs?: string; + detect(): boolean; + install(opts: ConnectOptions): Promise; +}; + +export type ConnectResult = + | { kind: "installed"; mutatedPath?: string; backupPath?: string } + | { kind: "already-wired"; mutatedPath?: string } + | { kind: "stub"; reason: string } + | { kind: "skipped"; reason: string }; diff --git a/src/cli/connect/util.ts b/src/cli/connect/util.ts new file mode 100644 index 0000000000..ce9110c655 --- /dev/null +++ b/src/cli/connect/util.ts @@ -0,0 +1,73 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + copyFileSync, + renameSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import * as p from "@clack/prompts"; + +export const AGENTMEMORY_MCP_BLOCK = { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + env: { + AGENTMEMORY_URL: "http://localhost:3111", + }, +}; + +export function backupsDir(): string { + return join(homedir(), ".agentmemory", "backups"); +} + +export function ensureBackupsDir(): string { + const dir = backupsDir(); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function timestampSlug(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +export function backupFile( + sourcePath: string, + agent: string, + ext = "json", +): string { + ensureBackupsDir(); + const stamp = timestampSlug(); + const target = join(backupsDir(), `${agent}-${stamp}.${ext}`); + copyFileSync(sourcePath, target); + return target; +} + +export function readJsonSafe(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as T; + } catch { + return null; + } +} + +export function writeJsonAtomic(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); + renameSync(tmp, path); +} + +export function logInstalled(label: string, target: string): void { + p.log.success(`${label} → wired into ${target}`); +} + +export function logAlreadyWired(label: string, target: string): void { + p.log.info(`${label} already wired in ${target} (use --force to re-install)`); +} + +export function logBackup(target: string): void { + p.log.info(`Backup: ${target}`); +} diff --git a/test/cli-connect.test.ts b/test/cli-connect.test.ts new file mode 100644 index 0000000000..4a6c0227ee --- /dev/null +++ b/test/cli-connect.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + ADAPTERS, + knownAgents, + resolveAdapter, +} from "../src/cli/connect/index.js"; +import type { ConnectAdapter } from "../src/cli/connect/types.js"; + +describe("agentmemory connect — dispatcher", () => { + it("resolves every known agent by lowercase name", () => { + for (const name of knownAgents()) { + const a = resolveAdapter(name); + expect(a, `expected adapter for ${name}`).not.toBeNull(); + expect(a!.name).toBe(name); + } + }); + + it("resolves case-insensitively", () => { + expect(resolveAdapter("Claude-Code")?.name).toBe("claude-code"); + expect(resolveAdapter("CURSOR")?.name).toBe("cursor"); + }); + + it("returns null for unknown agents", () => { + expect(resolveAdapter("nonexistent-agent")).toBeNull(); + expect(resolveAdapter("")).toBeNull(); + }); + + it("ships exactly the 8 agents specified by the spec", () => { + expect(knownAgents().sort()).toEqual( + [ + "claude-code", + "codex", + "cursor", + "gemini-cli", + "hermes", + "openclaw", + "openhuman", + "pi", + ].sort(), + ); + expect(ADAPTERS.length).toBe(8); + }); + + it("every adapter exposes detect() and install()", () => { + for (const a of ADAPTERS) { + expect(typeof a.detect).toBe("function"); + expect(typeof a.install).toBe("function"); + expect(typeof a.name).toBe("string"); + expect(typeof a.displayName).toBe("string"); + } + }); +}); + +describe("agentmemory connect — claude-code adapter (mock filesystem)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserprofile: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-connect-")); + originalHome = process.env["HOME"]; + originalUserprofile = process.env["USERPROFILE"]; + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + vi.resetModules(); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env["HOME"] = originalHome; + else delete process.env["HOME"]; + if (originalUserprofile !== undefined) + process.env["USERPROFILE"] = originalUserprofile; + else delete process.env["USERPROFILE"]; + rmSync(tmpHome, { recursive: true, force: true }); + vi.resetModules(); + }); + + async function loadAdapter(): Promise { + const mod = await import("../src/cli/connect/claude-code.js?t=" + Date.now()); + return (mod as { adapter: ConnectAdapter }).adapter; + } + + it("detect() returns false when ~/.claude doesn't exist", async () => { + const a = await loadAdapter(); + expect(a.detect()).toBe(false); + }); + + it("install() writes mcpServers.agentmemory into ~/.claude.json and is idempotent", async () => { + const claudeDir = join(tmpHome, ".claude"); + require("node:fs").mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(tmpHome, ".claude.json"), + JSON.stringify({ mcpServers: { other: { command: "x" } } }), + ); + + const a = await loadAdapter(); + expect(a.detect()).toBe(true); + + const first = await a.install({ dryRun: false, force: false }); + expect(first.kind).toBe("installed"); + + const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8")); + expect(config.mcpServers.agentmemory.command).toBe("npx"); + expect(config.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + expect(config.mcpServers.other.command).toBe("x"); + + const second = await a.install({ dryRun: false, force: false }); + expect(second.kind).toBe("already-wired"); + }); + + it("install() with --force re-writes even when already wired", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude.json"), + JSON.stringify({ + mcpServers: { + agentmemory: { command: "npx", args: ["-y", "@agentmemory/mcp"] }, + }, + }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: true }); + expect(result.kind).toBe("installed"); + }); + + it("install() with --dry-run does not mutate the file", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + const before = JSON.stringify({ mcpServers: {} }); + writeFileSync(join(tmpHome, ".claude.json"), before); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: true, force: false }); + expect(result.kind).toBe("installed"); + + const after = readFileSync(join(tmpHome, ".claude.json"), "utf-8"); + expect(after).toBe(before); + }); + + it("install() creates a backup file under ~/.agentmemory/backups/", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude.json"), + JSON.stringify({ mcpServers: {} }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + if (result.kind === "installed") { + expect(result.backupPath).toBeDefined(); + expect(existsSync(result.backupPath!)).toBe(true); + expect(result.backupPath!).toContain(".agentmemory/backups"); + } + }); +}); + +describe("agentmemory connect — stub adapters log + return stub", () => { + it("hermes adapter returns stub regardless of detect", async () => { + const { adapter } = await import("../src/cli/connect/hermes.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + }); + + it("openhuman adapter returns stub", async () => { + const { adapter } = await import("../src/cli/connect/openhuman.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + }); + + it("pi adapter returns stub", async () => { + const { adapter } = await import("../src/cli/connect/pi.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + }); +});