diff --git a/src/config-loader.ts b/src/config-loader.ts index 6129782..4de393f 100644 --- a/src/config-loader.ts +++ b/src/config-loader.ts @@ -6,6 +6,20 @@ import { join } from "node:path"; import type { AgentConfig } from "@opencode-ai/sdk"; +import { log } from "./utils/logger"; + +/** + * Strip trailing commas from JSON strings to be lenient with hand-edited configs. + * Matches quoted strings first (and keeps them intact) so that commas inside + * string values like "hello, }" are never modified. + */ +function stripTrailingCommas(json: string): string { + return json.replace(/"(?:[^"\\]|\\.)*"|,\s*([\]}])/g, (match, bracket) => { + if (bracket === undefined) return match; + return bracket; + }); +} + // Minimal type for provider validation - only what we need export interface ProviderInfo { id: string; @@ -30,7 +44,7 @@ function loadOpencodeConfig(configDir?: string): OpencodeConfig | null { try { const configPath = join(baseDir, "opencode.json"); const content = readFileSync(configPath, "utf-8"); - return JSON.parse(content) as OpencodeConfig; + return JSON.parse(stripTrailingCommas(content)) as OpencodeConfig; } catch { return null; } @@ -98,9 +112,21 @@ export async function loadMicodeConfig(configDir?: string): Promise; + content = await readFile(configPath, "utf-8"); + } catch (error: unknown) { + // File not found is expected and silent + if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + const message = error instanceof Error ? error.message : String(error); + log.warn("micode", `Failed to read micode.json: ${message}. Config overrides will be ignored.`); + return null; + } + + try { + const parsed = JSON.parse(stripTrailingCommas(content)) as Record; const result: MicodeConfig = {}; @@ -160,7 +186,10 @@ export async function loadMicodeConfig(configDir?: string): Promise try { const configPath = join(baseDir, "opencode.json"); const content = readFileSync(configPath, "utf-8"); - const config = JSON.parse(content) as { + const config = JSON.parse(stripTrailingCommas(content)) as { provider?: Record }>; }; diff --git a/tests/config-loader.test.ts b/tests/config-loader.test.ts index f803cd7..8160f8b 100644 --- a/tests/config-loader.test.ts +++ b/tests/config-loader.test.ts @@ -1,5 +1,5 @@ // tests/config-loader.test.ts -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -44,12 +44,61 @@ describe("config-loader", () => { expect(config?.agents?.brainstormer?.temperature).toBe(0.5); }); - it("should return null for invalid JSON", async () => { + it("should return null and warn for invalid JSON", async () => { const configPath = join(testConfigDir, "micode.json"); - writeFileSync(configPath, "{ invalid json }"); + writeFileSync(configPath, '{ "agents": BROKEN }'); + + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); const config = await loadMicodeConfig(testConfigDir); + expect(config).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to parse micode.json")); + + warnSpy.mockRestore(); + }); + + it("should parse JSON with trailing commas", async () => { + const configPath = join(testConfigDir, "micode.json"); + writeFileSync( + configPath, + `{ + "agents": { + "commander": { "model": "openai/gpt-4o", }, + "brainstormer": { "model": "anthropic/claude-opus-4-6", "temperature": 0.8, }, + }, + "compactionThreshold": 0.5, + }`, + ); + + const config = await loadMicodeConfig(testConfigDir); + + expect(config).not.toBeNull(); + expect(config?.agents?.commander?.model).toBe("openai/gpt-4o"); + expect(config?.agents?.brainstormer?.model).toBe("anthropic/claude-opus-4-6"); + expect(config?.agents?.brainstormer?.temperature).toBe(0.8); + expect(config?.compactionThreshold).toBe(0.5); + }); + + it("should not corrupt string values containing commas and brackets", async () => { + const configPath = join(testConfigDir, "micode.json"); + writeFileSync( + configPath, + JSON.stringify({ + fragments: { + brainstormer: ["hello, }", "keep this, ]too", 'escaped \\" comma, }'], + }, + }), + ); + + const config = await loadMicodeConfig(testConfigDir); + + expect(config).not.toBeNull(); + expect(config?.fragments?.brainstormer).toEqual([ + "hello, }", + "keep this, ]too", + 'escaped \\" comma, }', + ]); }); it("should handle empty agents object", async () => {