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/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 283882e..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'; @@ -90,6 +92,78 @@ 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 nestEntry(keys: string[], value: unknown): unknown { + return [...keys].reverse().reduce((acc, key) => ({ [key]: acc }), value); +} + +// JSONC-aware upsert for configs that may carry comments and trailing commas +// (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[], + 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 skip = (detail: string): WriteOutcome => ({ + label, + path, + action: 'skipped', + detail, + needsManualStep: true, + }); + + const text = readFileSync(path, 'utf-8'); + 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 reachedLeaf = true; + for (const key of keyPath.slice(0, -1)) { + const child = node[key]; + if (child === undefined) { + reachedLeaf = false; + break; + } + if (!isJsonObject(child)) return skip(`"${key}" is not a JSON object`); + node = child; + } + if (reachedLeaf && node[keyPath[keyPath.length - 1]!] !== undefined) { + return { label, path, action: 'unchanged' }; + } + + 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' }; +} + +// 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 +316,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 +325,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..5086dee 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, diff --git a/test/setup.test.ts b/test/setup.test.ts index 858440f..96039e8 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -3,10 +3,13 @@ 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, upsertJsonEntry, + upsertJsoncEntry, + opencodeConfigFile, upsertTomlSection, upsertGooseExtension, vscodeUserDirectory, @@ -134,6 +137,182 @@ 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); + }); + + 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( + 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,')); + 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', () => { + 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 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.equal(result.detail, 'existing file is not valid JSONC'); + 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('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'); + 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 +481,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);