diff --git a/package.json b/package.json index 3c7cb5375..1ce023ed4 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@clack/prompts": "^1.2.0", "dotenv": "^17.4.2", "iii-sdk": "0.11.2", + "jsonc-parser": "^3.3.1", "zod": "^4.0.0" }, "optionalDependencies": { diff --git a/src/cli/connect/json-mcp-adapter.ts b/src/cli/connect/json-mcp-adapter.ts index 35998e0dc..004b840c7 100644 --- a/src/cli/connect/json-mcp-adapter.ts +++ b/src/cli/connect/json-mcp-adapter.ts @@ -1,6 +1,8 @@ -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import * as p from "@clack/prompts"; +import { applyEdits, modify, parse } from "jsonc-parser"; +import type { ParseError } from "jsonc-parser"; import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; import { AGENTMEMORY_MCP_BLOCK, @@ -8,7 +10,7 @@ import { logAlreadyWired, logBackup, logInstalled, - readJsonSafe, + writeTextAtomic, writeJsonAtomic, } from "./util.js"; @@ -26,6 +28,9 @@ export type JsonMcpAdapterConfig = { // Wrapper key under which servers live. Default "mcpServers". // Zed uses "context_servers"; otherwise same shape. wrapperKey?: string; + // Some hosts, including Zed, store settings as JSONC with comments and + // trailing commas. Preserve those files with textual JSONC edits. + jsonc?: boolean; // Extra fields merged into the agentmemory entry. Droid requires // type: "stdio"; other hosts ignore unknown fields. extraEntryFields?: Record; @@ -33,6 +38,59 @@ export type JsonMcpAdapterConfig = { type McpEntry = typeof AGENTMEMORY_MCP_BLOCK; type McpConfig = Record; +type ReadConfigResult = + | { kind: "missing"; config: McpConfig } + | { kind: "parsed"; config: McpConfig; raw: string } + | { kind: "invalid"; reason: string }; + +const formattingOptions = { + insertSpaces: true, + tabSize: 2, + eol: "\n", + insertFinalNewline: true, +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function readMcpConfig(path: string, jsonc: boolean): ReadConfigResult { + if (!existsSync(path)) return { kind: "missing", config: {} }; + + const raw = readFileSync(path, "utf-8"); + try { + const parsed = jsonc ? parseJsonc(raw) : JSON.parse(raw); + if (parsed === undefined && raw.trim() === "") { + return { kind: "parsed", config: {}, raw }; + } + if (!isRecord(parsed)) { + return { kind: "invalid", reason: "top-level config is not an object" }; + } + return { kind: "parsed", config: parsed, raw }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { kind: "invalid", reason: message }; + } +} + +function parseJsonc(raw: string): unknown { + const errors: ParseError[] = []; + const parsed = parse(raw, errors, { + allowTrailingComma: true, + allowEmptyContent: true, + }); + if (errors.length > 0) { + const first = errors[0]; + throw new Error( + `JSONC parse error ${first.error} at offset ${first.offset}`, + ); + } + return parsed; +} + +function serverEntries(value: unknown): Record { + return isRecord(value) ? { ...(value as Record) } : {}; +} function entryMatches(entry: unknown): boolean { if (!entry || typeof entry !== "object") return false; @@ -60,11 +118,17 @@ export function createJsonMcpAdapter( }, async install(opts: ConnectOptions): Promise { - const existing = readJsonSafe(config.configPath); - const next: McpConfig = existing ? { ...existing } : {}; - const servers: Record = { - ...((next[wrapperKey] as Record) ?? {}), - }; + const jsonc = config.jsonc ?? false; + const existing = readMcpConfig(config.configPath, jsonc); + if (existing.kind === "invalid") { + p.log.error( + `${config.displayName}: ${config.configPath} could not be parsed (${existing.reason}); leaving it unchanged.`, + ); + return { kind: "skipped", reason: "invalid-config" }; + } + + const next: McpConfig = { ...existing.config }; + const servers = serverEntries(next[wrapperKey]); const alreadyHas = entryMatches(servers["agentmemory"]); if (alreadyHas && !opts.force) { @@ -92,12 +156,21 @@ export function createJsonMcpAdapter( ...(config.extraEntryFields ?? {}), }; next[wrapperKey] = servers; - writeJsonAtomic(config.configPath, next); + if (jsonc && existing.kind === "parsed") { + const edits = modify( + existing.raw, + [wrapperKey, "agentmemory"], + servers["agentmemory"], + { formattingOptions }, + ); + writeTextAtomic(config.configPath, applyEdits(existing.raw, edits)); + } else { + writeJsonAtomic(config.configPath, next); + } - const verify = readJsonSafe(config.configPath); - const verifyServers = verify?.[wrapperKey] as - | Record - | undefined; + const verify = readMcpConfig(config.configPath, jsonc); + const verifyServers = + verify.kind === "invalid" ? undefined : serverEntries(verify.config[wrapperKey]); if (!entryMatches(verifyServers?.["agentmemory"])) { p.log.error( `Verification failed: ${config.configPath} did not contain ${wrapperKey}.agentmemory after write.`, diff --git a/src/cli/connect/util.ts b/src/cli/connect/util.ts index 580cd4ee7..d657c473c 100644 --- a/src/cli/connect/util.ts +++ b/src/cli/connect/util.ts @@ -90,9 +90,14 @@ export function readJsonSafe(path: string): T | null { } export function writeJsonAtomic(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export function writeTextAtomic(path: string, value: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; - writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); + writeFileSync(tmp, value, "utf-8"); renameSync(tmp, path); } diff --git a/src/cli/connect/zed.ts b/src/cli/connect/zed.ts index bad7730d8..aaa20a08f 100644 --- a/src/cli/connect/zed.ts +++ b/src/cli/connect/zed.ts @@ -16,6 +16,7 @@ export const adapter = createJsonMcpAdapter({ detectDir: zedConfigDir, configPath: join(zedConfigDir, "settings.json"), wrapperKey: "context_servers", + jsonc: true, docs: "https://github.com/rohitg00/agentmemory#other-agents", protocolNote: "→ Using MCP via ~/.config/zed/settings.json (key: context_servers).", diff --git a/test/connect-new-agents.test.ts b/test/connect-new-agents.test.ts index 9ac4485ab..cfe2f7af4 100644 --- a/test/connect-new-agents.test.ts +++ b/test/connect-new-agents.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + readFileSync, + writeFileSync, + existsSync, +} from "node:fs"; import { tmpdir, platform } from "node:os"; import { join } from "node:path"; @@ -246,6 +253,37 @@ describe("connect: Zed", () => { expect(cfg.context_servers.agentmemory.args).toContain("@agentmemory/mcp"); expect(cfg.mcpServers).toBeUndefined(); }); + + it("preserves existing JSONC Zed settings when adding agentmemory", async () => { + const zedDir = join(home, ".config", "zed"); + const settingsPath = join(zedDir, "settings.json"); + mkdirSync(zedDir, { recursive: true }); + writeFileSync( + settingsPath, + `{ + // Keep user's editor preferences. + "vim_mode": true, + "context_servers": { + "existing": { + "command": "node", + "args": ["server.js"], + }, + }, +} +`, + ); + + const { adapter } = await import("../src/cli/connect/zed.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + + const updated = readFileSync(settingsPath, "utf-8"); + expect(updated).toContain("// Keep user's editor preferences."); + expect(updated).toContain('"vim_mode": true'); + expect(updated).toContain('"existing"'); + expect(updated).toContain('"agentmemory"'); + expect(updated).toContain("@agentmemory/mcp"); + }); }); describe("connect: Continue.dev", () => {