Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
3 changes: 3 additions & 0 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ async function main(): Promise<void> {
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',
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"jsonc-parser": "^3.3.1",
"ws": "^8.21.1"
},
"devDependencies": {
Expand Down
82 changes: 78 additions & 4 deletions src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +92,78 @@ export function upsertJsonEntry(
return { label, path, action: existed ? 'updated' : 'created' };
}

function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function nestEntry(keys: string[], value: unknown): unknown {
return [...keys].reverse().reduce<unknown>((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<string, unknown> = 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,
Expand Down Expand Up @@ -242,17 +316,17 @@ 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
),
],
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
Expand Down
2 changes: 2 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export {
SKILL_DIRECTORY_NAME,
writeSkillFile,
upsertJsonEntry,
upsertJsoncEntry,
opencodeConfigFile,
upsertTomlSection,
upsertGooseExtension,
vscodeUserDirectory,
Expand Down
198 changes: 198 additions & 0 deletions test/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> };
assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], ENTRY);
});

function parseOpencode(path: string): { mcp?: Record<string, unknown> } {
const errors: ParseError[] = [];
const parsed = parseJsonc(readFileSync(path, 'utf-8'), errors, { allowTrailingComma: true }) as {
mcp?: Record<string, unknown>;
};
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<string, unknown> };
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<string, unknown> };
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`;
Expand Down Expand Up @@ -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);
Expand Down
Loading