Skip to content

Commit 17f6c1d

Browse files
committed
fix: preserve Zed settings during connect
Signed-off-by: Igor Arkhipov <igor.arkhipov@joinhandshake.com>
1 parent f6f9e3c commit 17f6c1d

5 files changed

Lines changed: 132 additions & 14 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@clack/prompts": "^1.2.0",
6666
"dotenv": "^17.4.2",
6767
"iii-sdk": "0.11.2",
68+
"jsonc-parser": "^3.3.1",
6869
"zod": "^4.0.0"
6970
},
7071
"optionalDependencies": {

src/cli/connect/json-mcp-adapter.ts

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { existsSync, mkdirSync } from "node:fs";
1+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
22
import { dirname } from "node:path";
33
import * as p from "@clack/prompts";
4+
import { applyEdits, modify, parse } from "jsonc-parser";
5+
import type { ParseError } from "jsonc-parser";
46
import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js";
57
import {
68
AGENTMEMORY_MCP_BLOCK,
79
backupFile,
810
logAlreadyWired,
911
logBackup,
1012
logInstalled,
11-
readJsonSafe,
13+
writeTextAtomic,
1214
writeJsonAtomic,
1315
} from "./util.js";
1416

@@ -26,13 +28,69 @@ export type JsonMcpAdapterConfig = {
2628
// Wrapper key under which servers live. Default "mcpServers".
2729
// Zed uses "context_servers"; otherwise same shape.
2830
wrapperKey?: string;
31+
// Some hosts, including Zed, store settings as JSONC with comments and
32+
// trailing commas. Preserve those files with textual JSONC edits.
33+
jsonc?: boolean;
2934
// Extra fields merged into the agentmemory entry. Droid requires
3035
// type: "stdio"; other hosts ignore unknown fields.
3136
extraEntryFields?: Record<string, unknown>;
3237
};
3338

3439
type McpEntry = typeof AGENTMEMORY_MCP_BLOCK;
3540
type McpConfig = Record<string, unknown>;
41+
type ReadConfigResult =
42+
| { kind: "missing"; config: McpConfig }
43+
| { kind: "parsed"; config: McpConfig; raw: string }
44+
| { kind: "invalid"; reason: string };
45+
46+
const formattingOptions = {
47+
insertSpaces: true,
48+
tabSize: 2,
49+
eol: "\n",
50+
insertFinalNewline: true,
51+
};
52+
53+
function isRecord(value: unknown): value is Record<string, unknown> {
54+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
55+
}
56+
57+
function readMcpConfig(path: string, jsonc: boolean): ReadConfigResult {
58+
if (!existsSync(path)) return { kind: "missing", config: {} };
59+
60+
const raw = readFileSync(path, "utf-8");
61+
try {
62+
const parsed = jsonc ? parseJsonc(raw) : JSON.parse(raw);
63+
if (parsed === undefined && raw.trim() === "") {
64+
return { kind: "parsed", config: {}, raw };
65+
}
66+
if (!isRecord(parsed)) {
67+
return { kind: "invalid", reason: "top-level config is not an object" };
68+
}
69+
return { kind: "parsed", config: parsed, raw };
70+
} catch (error) {
71+
const message = error instanceof Error ? error.message : String(error);
72+
return { kind: "invalid", reason: message };
73+
}
74+
}
75+
76+
function parseJsonc(raw: string): unknown {
77+
const errors: ParseError[] = [];
78+
const parsed = parse(raw, errors, {
79+
allowTrailingComma: true,
80+
allowEmptyContent: true,
81+
});
82+
if (errors.length > 0) {
83+
const first = errors[0];
84+
throw new Error(
85+
`JSONC parse error ${first.error} at offset ${first.offset}`,
86+
);
87+
}
88+
return parsed;
89+
}
90+
91+
function serverEntries(value: unknown): Record<string, McpEntry> {
92+
return isRecord(value) ? { ...(value as Record<string, McpEntry>) } : {};
93+
}
3694

3795
function entryMatches(entry: unknown): boolean {
3896
if (!entry || typeof entry !== "object") return false;
@@ -60,11 +118,17 @@ export function createJsonMcpAdapter(
60118
},
61119

62120
async install(opts: ConnectOptions): Promise<ConnectResult> {
63-
const existing = readJsonSafe<McpConfig>(config.configPath);
64-
const next: McpConfig = existing ? { ...existing } : {};
65-
const servers: Record<string, McpEntry> = {
66-
...((next[wrapperKey] as Record<string, McpEntry>) ?? {}),
67-
};
121+
const jsonc = config.jsonc ?? false;
122+
const existing = readMcpConfig(config.configPath, jsonc);
123+
if (existing.kind === "invalid") {
124+
p.log.error(
125+
`${config.displayName}: ${config.configPath} could not be parsed (${existing.reason}); leaving it unchanged.`,
126+
);
127+
return { kind: "skipped", reason: "invalid-config" };
128+
}
129+
130+
const next: McpConfig = { ...existing.config };
131+
const servers = serverEntries(next[wrapperKey]);
68132

69133
const alreadyHas = entryMatches(servers["agentmemory"]);
70134
if (alreadyHas && !opts.force) {
@@ -92,12 +156,21 @@ export function createJsonMcpAdapter(
92156
...(config.extraEntryFields ?? {}),
93157
};
94158
next[wrapperKey] = servers;
95-
writeJsonAtomic(config.configPath, next);
159+
if (jsonc && existing.kind === "parsed") {
160+
const edits = modify(
161+
existing.raw,
162+
[wrapperKey, "agentmemory"],
163+
servers["agentmemory"],
164+
{ formattingOptions },
165+
);
166+
writeTextAtomic(config.configPath, applyEdits(existing.raw, edits));
167+
} else {
168+
writeJsonAtomic(config.configPath, next);
169+
}
96170

97-
const verify = readJsonSafe<McpConfig>(config.configPath);
98-
const verifyServers = verify?.[wrapperKey] as
99-
| Record<string, McpEntry>
100-
| undefined;
171+
const verify = readMcpConfig(config.configPath, jsonc);
172+
const verifyServers =
173+
verify.kind === "invalid" ? undefined : serverEntries(verify.config[wrapperKey]);
101174
if (!entryMatches(verifyServers?.["agentmemory"])) {
102175
p.log.error(
103176
`Verification failed: ${config.configPath} did not contain ${wrapperKey}.agentmemory after write.`,

src/cli/connect/util.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,14 @@ export function readJsonSafe<T = unknown>(path: string): T | null {
9090
}
9191

9292
export function writeJsonAtomic(path: string, value: unknown): void {
93+
mkdirSync(dirname(path), { recursive: true });
94+
writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
95+
}
96+
97+
export function writeTextAtomic(path: string, value: string): void {
9398
mkdirSync(dirname(path), { recursive: true });
9499
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
95-
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
100+
writeFileSync(tmp, value, "utf-8");
96101
renameSync(tmp, path);
97102
}
98103

src/cli/connect/zed.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const adapter = createJsonMcpAdapter({
1616
detectDir: zedConfigDir,
1717
configPath: join(zedConfigDir, "settings.json"),
1818
wrapperKey: "context_servers",
19+
jsonc: true,
1920
docs: "https://github.com/rohitg00/agentmemory#other-agents",
2021
protocolNote:
2122
"→ Using MCP via ~/.config/zed/settings.json (key: context_servers).",

test/connect-new-agents.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2-
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
2+
import {
3+
mkdtempSync,
4+
mkdirSync,
5+
rmSync,
6+
readFileSync,
7+
writeFileSync,
8+
existsSync,
9+
} from "node:fs";
310
import { tmpdir, platform } from "node:os";
411
import { join } from "node:path";
512

@@ -246,6 +253,37 @@ describe("connect: Zed", () => {
246253
expect(cfg.context_servers.agentmemory.args).toContain("@agentmemory/mcp");
247254
expect(cfg.mcpServers).toBeUndefined();
248255
});
256+
257+
it("preserves existing JSONC Zed settings when adding agentmemory", async () => {
258+
const zedDir = join(home, ".config", "zed");
259+
const settingsPath = join(zedDir, "settings.json");
260+
mkdirSync(zedDir, { recursive: true });
261+
writeFileSync(
262+
settingsPath,
263+
`{
264+
// Keep user's editor preferences.
265+
"vim_mode": true,
266+
"context_servers": {
267+
"existing": {
268+
"command": "node",
269+
"args": ["server.js"],
270+
},
271+
},
272+
}
273+
`,
274+
);
275+
276+
const { adapter } = await import("../src/cli/connect/zed.js");
277+
const result = await adapter.install({ dryRun: false, force: false });
278+
expect(result.kind).toBe("installed");
279+
280+
const updated = readFileSync(settingsPath, "utf-8");
281+
expect(updated).toContain("// Keep user's editor preferences.");
282+
expect(updated).toContain('"vim_mode": true');
283+
expect(updated).toContain('"existing"');
284+
expect(updated).toContain('"agentmemory"');
285+
expect(updated).toContain("@agentmemory/mcp");
286+
});
249287
});
250288

251289
describe("connect: Continue.dev", () => {

0 commit comments

Comments
 (0)