From 23900ff06a3077aee3fec78916ced156a9cc8e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 19:04:36 +0000 Subject: [PATCH 1/2] fix: edit an existing opencode.jsonc instead of creating a sibling opencode.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode reads and merges both opencode.json and opencode.jsonc (JSONC with comments and trailing commas, at the global ~/.config/opencode location and in the project root). setup wrote opencode.json unconditionally, so a user with an opencode.jsonc ended up with two config files. setup now targets the existing variant, preferring opencode.jsonc — the same order opencode itself uses when it edits its config — and edits it JSONC-aware: the entry is inserted textually so comments and formatting survive, and when the file can't be edited safely the outcome prints the exact snippet to add by hand instead of clobbering or duplicating. Nothing changes when neither variant exists: opencode.json is created as before, and repo-local writes remain behind the explicit --project flag. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017iuTu1C4JF3WLBodoKktHy --- README.md | 2 +- src/agents/registry.ts | 220 ++++++++++++++++++++++++++++++++++++++++- src/commands/setup.ts | 3 + test/setup.test.ts | 142 ++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4dda840..895d6d1 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ polylane setup --project # this project instead of the home dir |---|---|---| | Claude Code | `~/.claude/skills/polylane-cli/` | `~/.claude.json` | | Cursor | `~/.cursor/skills/polylane-cli/` | `~/.cursor/mcp.json` | -| OpenCode | `~/.config/opencode/skills/polylane-cli/` | `~/.config/opencode/opencode.json` | +| OpenCode | `~/.config/opencode/skills/polylane-cli/` | `~/.config/opencode/opencode.jsonc` if present, else `opencode.json` | | Codex CLI | `~/.codex/skills/polylane-cli/` | `~/.codex/config.toml` | | Pi | `~/.pi/agent/skills/polylane-cli/` | `~/.pi/agent/mcp.json` | | Warp | `~/.warp/skills/polylane-cli/` | `~/.warp/.mcp.json` | diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 283882e..21fc0ba 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -22,6 +22,7 @@ export interface WriteOutcome { action: WriteAction; detail?: string; needsManualStep?: boolean; + snippet?: string; } export function writeSkillFile(path: string, dryRun = false): WriteOutcome { @@ -90,6 +91,217 @@ export function upsertJsonEntry( return { label, path, action: existed ? 'updated' : 'created' }; } +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function skipString(s: string, i: number): number { + i++; + while (i < s.length) { + if (s[i] === '\\') i += 2; + else if (s[i] === '"') return i + 1; + else i++; + } + return i; +} + +function skipWs(s: string, i: number): number { + while (i < s.length && /\s/.test(s[i]!)) i++; + return i; +} + +// Blank out comments and trailing commas with spaces, so the result is +// strict JSON with every remaining character at its original offset. +function stripJsonc(text: string): string { + const chars = text.split(''); + for (let i = 0; i < chars.length; ) { + if (chars[i] === '"') { + i = skipString(text, i); + } else if (chars[i] === '/' && chars[i + 1] === '/') { + while (i < chars.length && chars[i] !== '\n') chars[i++] = ' '; + } else if (chars[i] === '/' && chars[i + 1] === '*') { + chars[i] = chars[i + 1] = ' '; + i += 2; + while (i < chars.length && !(chars[i] === '*' && chars[i + 1] === '/')) { + if (chars[i] !== '\n') chars[i] = ' '; + i++; + } + if (i < chars.length) { + chars[i] = chars[i + 1] = ' '; + i += 2; + } + } else { + i++; + } + } + const blanked = chars.join(''); + for (let i = 0; i < chars.length; ) { + if (chars[i] === '"') { + i = skipString(blanked, i); + } else if (chars[i] === ',') { + const j = skipWs(blanked, i + 1); + if (blanked[j] === '}' || blanked[j] === ']') chars[i] = ' '; + i++; + } else { + i++; + } + } + return chars.join(''); +} + +function skipValue(s: string, i: number): number { + if (s[i] === '"') return skipString(s, i); + if (s[i] === '{' || s[i] === '[') { + let depth = 0; + while (i < s.length) { + if (s[i] === '"') { + i = skipString(s, i); + continue; + } + if (s[i] === '{' || s[i] === '[') depth++; + else if (s[i] === '}' || s[i] === ']') { + depth--; + if (depth === 0) return i + 1; + } + i++; + } + return i; + } + while (i < s.length && !/[\s,}\]]/.test(s[i]!)) i++; + return i; +} + +// Index of the `{` opening the object reached by `keys` ([] = root), or -1. +function objectOpenIndex(s: string, keys: string[]): number { + let i = skipWs(s, 0); + if (s[i] !== '{') return -1; + for (const key of keys) { + let j = skipWs(s, i + 1); + let valueAt = -1; + while (j < s.length && s[j] !== '}') { + if (s[j] !== '"') return -1; + const keyEnd = skipString(s, j); + let name: unknown; + try { + name = JSON.parse(s.slice(j, keyEnd)); + } catch { + return -1; + } + j = skipWs(s, keyEnd); + if (s[j] !== ':') return -1; + j = skipWs(s, j + 1); + if (name === key) { + valueAt = j; + break; + } + j = skipWs(s, skipValue(s, j)); + if (s[j] === ',') j = skipWs(s, j + 1); + } + if (valueAt < 0 || s[valueAt] !== '{') return -1; + i = valueAt; + } + return i; +} + +function nestEntry(keys: string[], value: unknown): unknown { + return [...keys].reverse().reduce((acc, key) => ({ [key]: acc }), value); +} + +export function manualSnippet(keyPath: string[], value: unknown): string { + return JSON.stringify(nestEntry(keyPath, value), null, 2).split('\n').slice(1, -1).join('\n'); +} + +// JSONC-aware upsert for configs that may carry comments and trailing commas +// (opencode parses everything as JSONC). Existing files are edited by textual +// insertion so comments and formatting survive; when the file can't be edited +// safely, the outcome carries a snippet to add by hand instead of clobbering. +export function upsertJsoncEntry( + path: string, + keyPath: string[], + value: unknown, + dryRun = false +): WriteOutcome { + const label = 'MCP server'; + if (!existsSync(path)) { + if (!dryRun) { + ensureDir(dirname(path)); + writeFileSync(path, JSON.stringify(nestEntry(keyPath, value), null, 2) + '\n', 'utf-8'); + } + return { label, path, action: 'created' }; + } + + const manual = (detail: string): WriteOutcome => ({ + label, + path, + action: 'skipped', + detail, + needsManualStep: true, + snippet: manualSnippet(keyPath, value), + }); + + const text = readFileSync(path, 'utf-8'); + const stripped = stripJsonc(text); + let parsed: unknown; + try { + parsed = JSON.parse(stripped); + } catch { + return manual('existing file is not valid JSON'); + } + if (!isJsonObject(parsed)) { + return manual('existing file is not a JSON object'); + } + + let node: Record = parsed; + let depth = 0; + for (const key of keyPath.slice(0, -1)) { + const child = node[key]; + if (child === undefined) break; + if (!isJsonObject(child)) return manual(`"${key}" is not a JSON object`); + node = child; + depth++; + } + if (depth === keyPath.length - 1 && node[keyPath[depth]!] !== undefined) { + return { label, path, action: 'unchanged' }; + } + + const openIdx = objectOpenIndex(stripped, keyPath.slice(0, depth)); + if (openIdx < 0) return manual('add the entry manually'); + + const unit = /\n([ \t]+)\S/.exec(text)?.[1] ?? ' '; + const memberIndent = unit.repeat(depth + 1); + const rendered = JSON.stringify(nestEntry(keyPath.slice(depth + 1), value), null, unit) + .split('\n') + .map((line, index) => (index === 0 ? line : memberIndent + line)) + .join('\n'); + const entry = `${JSON.stringify(keyPath[depth])}: ${rendered}`; + const empty = stripped[skipWs(stripped, openIdx + 1)] === '}'; + const insertion = empty + ? `\n${memberIndent}${entry}\n${unit.repeat(depth)}` + : `\n${memberIndent}${entry},`; + const next = text.slice(0, openIdx + 1) + insertion + text.slice(openIdx + 1); + + let probe: unknown; + try { + probe = JSON.parse(stripJsonc(next)); + } catch { + probe = undefined; + } + for (const key of keyPath) { + probe = isJsonObject(probe) ? probe[key] : undefined; + } + if (probe === undefined) return manual('add the entry manually'); + + if (!dryRun) writeFileSync(path, next, 'utf-8'); + return { label, path, action: 'updated' }; +} + +// opencode reads and merges both opencode.json and opencode.jsonc; edit the +// file that exists instead of creating a sibling next to it. +export function opencodeConfigFile(dir: string): string { + const jsonc = join(dir, 'opencode.jsonc'); + return existsSync(jsonc) ? jsonc : join(dir, 'opencode.json'); +} + export function upsertTomlSection( path: string, sectionHeader: string, @@ -242,8 +454,8 @@ export const AGENTS: AgentSetup[] = [ detect: (home) => existsSync(join(home, '.config', 'opencode')), user: (home, dryRun) => [ writeSkillFile(skillFile(join(home, '.config', 'opencode')), dryRun), - upsertJsonEntry( - join(home, '.config', 'opencode', 'opencode.json'), + upsertJsoncEntry( + opencodeConfigFile(join(home, '.config', 'opencode')), ['mcp', MCP_SERVER_NAME], { type: 'remote', url: MCP_SERVER_URL }, dryRun @@ -251,8 +463,8 @@ export const AGENTS: AgentSetup[] = [ ], project: (projectDir, dryRun) => [ writeSkillFile(skillFile(join(projectDir, '.opencode')), dryRun), - upsertJsonEntry( - join(projectDir, 'opencode.json'), + upsertJsoncEntry( + opencodeConfigFile(projectDir), ['mcp', MCP_SERVER_NAME], { type: 'remote', url: MCP_SERVER_URL }, dryRun diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 33c6727..025bcc4 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -21,6 +21,8 @@ export { SKILL_DIRECTORY_NAME, writeSkillFile, upsertJsonEntry, + upsertJsoncEntry, + opencodeConfigFile, upsertTomlSection, upsertGooseExtension, vscodeUserDirectory, @@ -154,6 +156,7 @@ export const setupCommand: Command = { const state = config.dryRun && changed ? `would be ${base}` : base; const detail = outcome.detail ? ` (${outcome.detail})` : ''; say(`${agent.id}: ${outcome.label} ${state}: ${outcome.path}${detail}`); + if (outcome.snippet) say(`${agent.id}: add to ${outcome.path}:\n${outcome.snippet}`); if (outcome.needsManualStep) { say(`${agent.id}: register it manually: https://docs.polylane.com/coding-agents/platform-mcp`); } diff --git a/test/setup.test.ts b/test/setup.test.ts index 858440f..d734c6c 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -7,6 +7,8 @@ import { AGENTS, writeSkillFile, upsertJsonEntry, + upsertJsoncEntry, + opencodeConfigFile, upsertTomlSection, upsertGooseExtension, vscodeUserDirectory, @@ -134,6 +136,127 @@ describe('upsertJsonEntry', () => { }); }); +describe('upsertJsoncEntry', () => { + const ENTRY = { type: 'remote', url: MCP_SERVER_URL }; + + it('creates the file and nested key path', () => { + const path = join(tempDir, 'opencode.json'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'created'); + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { mcp: Record }; + assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], ENTRY); + }); + + it('inserts into a commented file without touching the comments', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync( + path, + '{\n // my theme\n "theme": "dark", // trailing\n /* block */\n "autoupdate": true,\n}\n', + 'utf-8' + ); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'updated'); + const content = readFileSync(path, 'utf-8'); + assert.ok(content.includes('// my theme')); + assert.ok(content.includes('// trailing')); + assert.ok(content.includes('/* block */')); + assert.ok(content.includes('"autoupdate": true,')); + }); + + it('inserts into an existing mcp object, keeping sibling servers', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync( + path, + '{\n "mcp": {\n // local server\n "other": { "type": "local", "command": ["run"] },\n },\n}\n', + 'utf-8' + ); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'updated'); + const content = readFileSync(path, 'utf-8'); + assert.ok(content.includes('// local server')); + assert.ok(content.includes(`"${MCP_SERVER_NAME}": {`)); + assert.ok(content.indexOf(MCP_SERVER_NAME) < content.indexOf('other')); + }); + + it('edits a plain-JSON file in place without reformatting it', () => { + const path = join(tempDir, 'opencode.json'); + const original = '{\n "theme": "dark"\n}\n'; + writeFileSync(path, original, 'utf-8'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'updated'); + const content = readFileSync(path, 'utf-8'); + assert.ok(content.includes('"theme": "dark"')); + const parsed = JSON.parse(content) as { theme: string; mcp: Record }; + assert.equal(parsed.theme, 'dark'); + assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], ENTRY); + }); + + it('leaves an existing entry untouched, comments included', () => { + const path = join(tempDir, 'opencode.jsonc'); + const original = `{\n // keep me\n "mcp": { "${MCP_SERVER_NAME}": { "type": "remote", "url": "${MCP_SERVER_URL}", "headers": { "x-api-key": "sk" } } },\n}\n`; + writeFileSync(path, original, 'utf-8'); + assert.equal(upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY).action, 'unchanged'); + assert.equal(readFileSync(path, 'utf-8'), original); + }); + + it('skips with a snippet when the file is not valid JSONC', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync(path, '{ "theme": ', 'utf-8'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'skipped'); + assert.equal(result.needsManualStep, true); + assert.ok(result.snippet?.includes(`"${MCP_SERVER_NAME}"`)); + assert.equal(readFileSync(path, 'utf-8'), '{ "theme": '); + }); + + it('skips when mcp is not an object', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync(path, '// c\n{ "mcp": "oops" }', 'utf-8'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'skipped'); + assert.equal(readFileSync(path, 'utf-8'), '// c\n{ "mcp": "oops" }'); + }); + + it('handles an empty root object', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync(path, '{}\n', 'utf-8'); + assert.equal(upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY).action, 'updated'); + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { mcp: Record }; + assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], ENTRY); + }); + + it('ignores braces and slashes inside strings', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync(path, '{\n // note\n "instructions": ["a {weird} // path \\" (}"],\n}\n', 'utf-8'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'updated'); + const content = readFileSync(path, 'utf-8'); + assert.ok(content.includes('"a {weird} // path \\" (}"')); + }); + + it('does not write in dry-run mode', () => { + const created = upsertJsoncEntry(join(tempDir, 'opencode.json'), ['mcp', MCP_SERVER_NAME], ENTRY, true); + assert.equal(created.action, 'created'); + assert.equal(existsSync(join(tempDir, 'opencode.json')), false); + const path = join(tempDir, 'opencode.jsonc'); + const original = '{\n // c\n "theme": "dark",\n}\n'; + writeFileSync(path, original, 'utf-8'); + assert.equal(upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY, true).action, 'updated'); + assert.equal(readFileSync(path, 'utf-8'), original); + }); +}); + +describe('opencodeConfigFile', () => { + it('targets an existing opencode.jsonc over creating opencode.json', () => { + writeFileSync(join(tempDir, 'opencode.jsonc'), '{}\n', 'utf-8'); + assert.equal(opencodeConfigFile(tempDir), join(tempDir, 'opencode.jsonc')); + }); + + it('defaults to opencode.json when no variant exists', () => { + assert.equal(opencodeConfigFile(tempDir), join(tempDir, 'opencode.json')); + }); +}); + describe('upsertTomlSection', () => { const header = `[mcp_servers.${MCP_SERVER_NAME}]`; const body = `url = "${MCP_SERVER_URL}"\n`; @@ -302,6 +425,25 @@ describe('agent definitions', () => { assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], { type: 'remote', url: MCP_SERVER_URL }); }); + it('edits an existing opencode.jsonc instead of creating a sibling opencode.json', () => { + const dir = join(tempDir, '.config', 'opencode'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'opencode.jsonc'), '{\n // keep\n "theme": "dark",\n}\n', 'utf-8'); + const outcomes = agent('opencode').user(tempDir, false); + assert.equal(outcomes[1]?.path, join(dir, 'opencode.jsonc')); + assert.equal(outcomes[1]?.action, 'updated'); + assert.equal(existsSync(join(dir, 'opencode.json')), false); + assert.ok(readFileSync(join(dir, 'opencode.jsonc'), 'utf-8').includes('// keep')); + }); + + it('edits a project-level opencode.jsonc instead of creating a sibling opencode.json', () => { + writeFileSync(join(tempDir, 'opencode.jsonc'), '{}\n', 'utf-8'); + const outcomes = agent('opencode').project!(tempDir, false); + assert.equal(outcomes[1]?.path, join(tempDir, 'opencode.jsonc')); + assert.equal(outcomes[1]?.action, 'updated'); + assert.equal(existsSync(join(tempDir, 'opencode.json')), false); + }); + it('configures codex with a skill and a config.toml section', () => { agent('codex').user(tempDir, false); assert.equal(readFileSync(join(tempDir, '.codex', 'skills', 'polylane-cli', 'SKILL.md'), 'utf-8'), SKILL_MD); From 7747f98eb0ea92b3379e0cf4f62b7928e604b318 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 19:46:52 +0000 Subject: [PATCH 2/2] fix: always edit opencode config in place with jsonc-parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: never print a snippet for the user to copy. The upsert now edits any valid JSONC in place using jsonc-parser — the library opencode itself edits its config with — so comments and the formatting of existing members survive, and the entry is inserted whether or not an mcp block exists. Only a genuinely malformed file (one opencode couldn't parse either) is refused, as a clear skipped outcome naming the file. The WriteOutcome.snippet plumbing is gone. The bundle aliases jsonc-parser to its ESM build; the UMD default passes require into its factory, which esbuild can't follow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017iuTu1C4JF3WLBodoKktHy --- build.ts | 3 + package-lock.json | 7 ++ package.json | 1 + src/agents/registry.ts | 184 ++++++----------------------------------- src/commands/setup.ts | 1 - test/setup.test.ts | 60 +++++++++++++- 6 files changed, 92 insertions(+), 164 deletions(-) diff --git a/build.ts b/build.ts index 77582f0..68472ae 100644 --- a/build.ts +++ b/build.ts @@ -48,6 +48,9 @@ async function main(): Promise { const result = await build({ entryPoints: ['src/main.ts'], bundle: true, + // jsonc-parser's default UMD entry passes `require` into its factory, + // which esbuild can't follow; its ESM build bundles cleanly. + alias: { 'jsonc-parser': 'jsonc-parser/lib/esm/main.js' }, platform: 'node', target: 'node18', format: 'esm', diff --git a/package-lock.json b/package-lock.json index 2a095a0..947a5be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", + "jsonc-parser": "^3.3.1", "ws": "^8.21.1" }, "bin": { @@ -1671,6 +1672,12 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 69dae7d..7c4f4ec 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@clack/prompts": "^0.7.0", + "jsonc-parser": "^3.3.1", "ws": "^8.21.1" }, "devDependencies": { diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 21fc0ba..462f0ec 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -2,6 +2,8 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'; + import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; import { ensureDir } from '../utils/fs'; @@ -22,7 +24,6 @@ export interface WriteOutcome { action: WriteAction; detail?: string; needsManualStep?: boolean; - snippet?: string; } export function writeSkillFile(path: string, dryRun = false): WriteOutcome { @@ -95,126 +96,15 @@ function isJsonObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function skipString(s: string, i: number): number { - i++; - while (i < s.length) { - if (s[i] === '\\') i += 2; - else if (s[i] === '"') return i + 1; - else i++; - } - return i; -} - -function skipWs(s: string, i: number): number { - while (i < s.length && /\s/.test(s[i]!)) i++; - return i; -} - -// Blank out comments and trailing commas with spaces, so the result is -// strict JSON with every remaining character at its original offset. -function stripJsonc(text: string): string { - const chars = text.split(''); - for (let i = 0; i < chars.length; ) { - if (chars[i] === '"') { - i = skipString(text, i); - } else if (chars[i] === '/' && chars[i + 1] === '/') { - while (i < chars.length && chars[i] !== '\n') chars[i++] = ' '; - } else if (chars[i] === '/' && chars[i + 1] === '*') { - chars[i] = chars[i + 1] = ' '; - i += 2; - while (i < chars.length && !(chars[i] === '*' && chars[i + 1] === '/')) { - if (chars[i] !== '\n') chars[i] = ' '; - i++; - } - if (i < chars.length) { - chars[i] = chars[i + 1] = ' '; - i += 2; - } - } else { - i++; - } - } - const blanked = chars.join(''); - for (let i = 0; i < chars.length; ) { - if (chars[i] === '"') { - i = skipString(blanked, i); - } else if (chars[i] === ',') { - const j = skipWs(blanked, i + 1); - if (blanked[j] === '}' || blanked[j] === ']') chars[i] = ' '; - i++; - } else { - i++; - } - } - return chars.join(''); -} - -function skipValue(s: string, i: number): number { - if (s[i] === '"') return skipString(s, i); - if (s[i] === '{' || s[i] === '[') { - let depth = 0; - while (i < s.length) { - if (s[i] === '"') { - i = skipString(s, i); - continue; - } - if (s[i] === '{' || s[i] === '[') depth++; - else if (s[i] === '}' || s[i] === ']') { - depth--; - if (depth === 0) return i + 1; - } - i++; - } - return i; - } - while (i < s.length && !/[\s,}\]]/.test(s[i]!)) i++; - return i; -} - -// Index of the `{` opening the object reached by `keys` ([] = root), or -1. -function objectOpenIndex(s: string, keys: string[]): number { - let i = skipWs(s, 0); - if (s[i] !== '{') return -1; - for (const key of keys) { - let j = skipWs(s, i + 1); - let valueAt = -1; - while (j < s.length && s[j] !== '}') { - if (s[j] !== '"') return -1; - const keyEnd = skipString(s, j); - let name: unknown; - try { - name = JSON.parse(s.slice(j, keyEnd)); - } catch { - return -1; - } - j = skipWs(s, keyEnd); - if (s[j] !== ':') return -1; - j = skipWs(s, j + 1); - if (name === key) { - valueAt = j; - break; - } - j = skipWs(s, skipValue(s, j)); - if (s[j] === ',') j = skipWs(s, j + 1); - } - if (valueAt < 0 || s[valueAt] !== '{') return -1; - i = valueAt; - } - return i; -} - function nestEntry(keys: string[], value: unknown): unknown { return [...keys].reverse().reduce((acc, key) => ({ [key]: acc }), value); } -export function manualSnippet(keyPath: string[], value: unknown): string { - return JSON.stringify(nestEntry(keyPath, value), null, 2).split('\n').slice(1, -1).join('\n'); -} - // JSONC-aware upsert for configs that may carry comments and trailing commas -// (opencode parses everything as JSONC). Existing files are edited by textual -// insertion so comments and formatting survive; when the file can't be edited -// safely, the outcome carries a snippet to add by hand instead of clobbering. +// (opencode parses everything as JSONC). Existing files are edited in place +// with jsonc-parser — the same library opencode uses on its own config — so +// comments and the formatting of existing members survive. Only a file +// opencode itself couldn't parse is refused. export function upsertJsoncEntry( path: string, keyPath: string[], @@ -230,68 +120,40 @@ export function upsertJsoncEntry( return { label, path, action: 'created' }; } - const manual = (detail: string): WriteOutcome => ({ + const skip = (detail: string): WriteOutcome => ({ label, path, action: 'skipped', detail, needsManualStep: true, - snippet: manualSnippet(keyPath, value), }); const text = readFileSync(path, 'utf-8'); - const stripped = stripJsonc(text); - let parsed: unknown; - try { - parsed = JSON.parse(stripped); - } catch { - return manual('existing file is not valid JSON'); - } - if (!isJsonObject(parsed)) { - return manual('existing file is not a JSON object'); - } + const errors: ParseError[] = []; + const parsed: unknown = parseJsonc(text, errors, { allowTrailingComma: true }); + if (errors.length > 0) return skip('existing file is not valid JSONC'); + if (!isJsonObject(parsed)) return skip('existing file is not a JSON object'); let node: Record = parsed; - let depth = 0; + let reachedLeaf = true; for (const key of keyPath.slice(0, -1)) { const child = node[key]; - if (child === undefined) break; - if (!isJsonObject(child)) return manual(`"${key}" is not a JSON object`); + if (child === undefined) { + reachedLeaf = false; + break; + } + if (!isJsonObject(child)) return skip(`"${key}" is not a JSON object`); node = child; - depth++; } - if (depth === keyPath.length - 1 && node[keyPath[depth]!] !== undefined) { + if (reachedLeaf && node[keyPath[keyPath.length - 1]!] !== undefined) { return { label, path, action: 'unchanged' }; } - const openIdx = objectOpenIndex(stripped, keyPath.slice(0, depth)); - if (openIdx < 0) return manual('add the entry manually'); - - const unit = /\n([ \t]+)\S/.exec(text)?.[1] ?? ' '; - const memberIndent = unit.repeat(depth + 1); - const rendered = JSON.stringify(nestEntry(keyPath.slice(depth + 1), value), null, unit) - .split('\n') - .map((line, index) => (index === 0 ? line : memberIndent + line)) - .join('\n'); - const entry = `${JSON.stringify(keyPath[depth])}: ${rendered}`; - const empty = stripped[skipWs(stripped, openIdx + 1)] === '}'; - const insertion = empty - ? `\n${memberIndent}${entry}\n${unit.repeat(depth)}` - : `\n${memberIndent}${entry},`; - const next = text.slice(0, openIdx + 1) + insertion + text.slice(openIdx + 1); - - let probe: unknown; - try { - probe = JSON.parse(stripJsonc(next)); - } catch { - probe = undefined; - } - for (const key of keyPath) { - probe = isJsonObject(probe) ? probe[key] : undefined; - } - if (probe === undefined) return manual('add the entry manually'); - - if (!dryRun) writeFileSync(path, next, 'utf-8'); + const edits = modify(text, keyPath, value, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + getInsertionIndex: () => 0, + }); + if (!dryRun) writeFileSync(path, applyEdits(text, edits), 'utf-8'); return { label, path, action: 'updated' }; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 025bcc4..5086dee 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -156,7 +156,6 @@ export const setupCommand: Command = { const state = config.dryRun && changed ? `would be ${base}` : base; const detail = outcome.detail ? ` (${outcome.detail})` : ''; say(`${agent.id}: ${outcome.label} ${state}: ${outcome.path}${detail}`); - if (outcome.snippet) say(`${agent.id}: add to ${outcome.path}:\n${outcome.snippet}`); if (outcome.needsManualStep) { say(`${agent.id}: register it manually: https://docs.polylane.com/coding-agents/platform-mcp`); } diff --git a/test/setup.test.ts b/test/setup.test.ts index d734c6c..96039e8 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; import { AGENTS, writeSkillFile, @@ -147,6 +148,15 @@ describe('upsertJsoncEntry', () => { assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], ENTRY); }); + function parseOpencode(path: string): { mcp?: Record } { + const errors: ParseError[] = []; + const parsed = parseJsonc(readFileSync(path, 'utf-8'), errors, { allowTrailingComma: true }) as { + mcp?: Record; + }; + assert.equal(errors.length, 0, 'edited file parses as JSONC'); + return parsed; + } + it('inserts into a commented file without touching the comments', () => { const path = join(tempDir, 'opencode.jsonc'); writeFileSync( @@ -161,6 +171,36 @@ describe('upsertJsoncEntry', () => { assert.ok(content.includes('// trailing')); assert.ok(content.includes('/* block */')); assert.ok(content.includes('"autoupdate": true,')); + assert.deepEqual(parseOpencode(path).mcp?.[MCP_SERVER_NAME], ENTRY); + }); + + it('edits a heavily commented config with trailing commas everywhere', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync( + path, + [ + '// opencode config', + '{', + ' "$schema": "https://opencode.ai/config.json", // schema', + ' /* providers', + ' multi-line */', + ' "provider": {', + ' "anthropic": { "options": { "timeout": 600000, }, },', + ' }, // end providers', + ' "instructions": ["docs/*.md",],', + '}', + '// eof', + '', + ].join('\n'), + 'utf-8' + ); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'updated'); + const content = readFileSync(path, 'utf-8'); + for (const kept of ['// opencode config', '/* providers', '// end providers', '"docs/*.md",', '// eof']) { + assert.ok(content.includes(kept), `kept: ${kept}`); + } + assert.deepEqual(parseOpencode(path).mcp?.[MCP_SERVER_NAME], ENTRY); }); it('inserts into an existing mcp object, keeping sibling servers', () => { @@ -199,13 +239,13 @@ describe('upsertJsoncEntry', () => { assert.equal(readFileSync(path, 'utf-8'), original); }); - it('skips with a snippet when the file is not valid JSONC', () => { + it('skips only a genuinely malformed file, leaving it untouched', () => { const path = join(tempDir, 'opencode.jsonc'); writeFileSync(path, '{ "theme": ', 'utf-8'); const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); assert.equal(result.action, 'skipped'); assert.equal(result.needsManualStep, true); - assert.ok(result.snippet?.includes(`"${MCP_SERVER_NAME}"`)); + assert.equal(result.detail, 'existing file is not valid JSONC'); assert.equal(readFileSync(path, 'utf-8'), '{ "theme": '); }); @@ -217,6 +257,22 @@ describe('upsertJsoncEntry', () => { assert.equal(readFileSync(path, 'utf-8'), '// c\n{ "mcp": "oops" }'); }); + it('treats single-quoted strings as malformed, like opencode does', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync(path, "{ 'theme': 'dark' }", 'utf-8'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'skipped'); + assert.equal(readFileSync(path, 'utf-8'), "{ 'theme': 'dark' }"); + }); + + it('does not report unchanged for a top-level key that only matches the leaf name', () => { + const path = join(tempDir, 'opencode.jsonc'); + writeFileSync(path, `{ "${MCP_SERVER_NAME}": true }`, 'utf-8'); + const result = upsertJsoncEntry(path, ['mcp', MCP_SERVER_NAME], ENTRY); + assert.equal(result.action, 'updated'); + assert.deepEqual(parseOpencode(path).mcp?.[MCP_SERVER_NAME], ENTRY); + }); + it('handles an empty root object', () => { const path = join(tempDir, 'opencode.jsonc'); writeFileSync(path, '{}\n', 'utf-8');