diff --git a/README.md b/README.md index 6e82ed6b..920eeff5 100644 --- a/README.md +++ b/README.md @@ -696,6 +696,36 @@ Schema autocomplete supported: } ``` +### JSONC Support + +The `oh-my-opencode` configuration file supports JSONC (JSON with Comments): +- Line comments: `// comment` +- Block comments: `/* comment */` +- Trailing commas: `{ "key": "value", }` + +When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc` takes priority. + +**Example with comments:** + +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", + + // Enable Google Gemini via Antigravity OAuth + "google_auth": false, + + /* Agent overrides - customize models for specific tasks */ + "agents": { + "oracle": { + "model": "openai/gpt-5.2" // GPT for strategic reasoning + }, + "explore": { + "model": "opencode/grok-code" // Free & fast for exploration + }, + }, +} +``` + ### Google Auth **Recommended**: Use the external [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](#google-gemini-antigravity-oauth). diff --git a/bun.lock b/bun.lock index 84bead3f..ea24f0a5 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@opencode-ai/sdk": "^1.0.162", "commander": "^14.0.2", "hono": "^4.10.4", + "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "xdg-basedir": "^5.1.0", @@ -110,6 +111,8 @@ "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/package.json b/package.json index f09600a8..891c362c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@opencode-ai/sdk": "^1.0.162", "commander": "^14.0.2", "hono": "^4.10.4", + "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "picomatch": "^4.0.2", "xdg-basedir": "^5.1.0", diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 0e95c429..ab3e1e1c 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +import { parseJsonc } from "../shared" import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types" const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") @@ -39,80 +40,10 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } { return { format: "none", path: OPENCODE_JSON } } -function stripJsoncComments(content: string): string { - let result = "" - let i = 0 - let inString = false - let escape = false - - while (i < content.length) { - const char = content[i] - - if (escape) { - result += char - escape = false - i++ - continue - } - - if (char === "\\") { - result += char - escape = true - i++ - continue - } - - if (char === '"' && !inString) { - inString = true - result += char - i++ - continue - } - - if (char === '"' && inString) { - inString = false - result += char - i++ - continue - } - - if (inString) { - result += char - i++ - continue - } - - // Outside string - check for comments - if (char === "/" && content[i + 1] === "/") { - // Line comment - skip to end of line - while (i < content.length && content[i] !== "\n") { - i++ - } - continue - } - - if (char === "/" && content[i + 1] === "*") { - // Block comment - skip to */ - i += 2 - while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) { - i++ - } - i += 2 - continue - } - - result += char - i++ - } - - return result.replace(/,(\s*[}\]])/g, "$1") -} - function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null { try { const content = readFileSync(path, "utf-8") - const cleaned = isJsonc ? stripJsoncComments(content) : content - return JSON.parse(cleaned) as OpenCodeConfig + return parseJsonc(content) } catch { return null } @@ -252,8 +183,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult if (existsSync(OMO_CONFIG)) { const content = readFileSync(OMO_CONFIG, "utf-8") - const cleaned = stripJsoncComments(content) - const existing = JSON.parse(cleaned) as Record + const existing = parseJsonc>(content) delete existing.agents const merged = deepMerge(existing, newConfig) writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n") @@ -484,7 +414,7 @@ export function detectCurrentConfig(): DetectedConfig { try { const content = readFileSync(OMO_CONFIG, "utf-8") - const omoConfig = JSON.parse(stripJsoncComments(content)) as OmoConfigData + const omoConfig = parseJsonc(content) const agents = omoConfig.agents ?? {} diff --git a/src/index.ts b/src/index.ts index a6f4bca6..5c6d75ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,7 +47,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, import { BackgroundManager } from "./features/background-agent"; import { createBuiltinMcps } from "./mcp"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config"; -import { log, deepMerge, getUserConfigDir, addConfigLoadError } from "./shared"; +import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile } from "./shared"; import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt"; import * as fs from "fs"; import * as path from "path"; @@ -119,7 +119,7 @@ function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | try { if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, "utf-8"); - const rawConfig = JSON.parse(content); + const rawConfig = parseJsonc>(content); migrateConfigFile(configPath, rawConfig); @@ -201,19 +201,15 @@ function mergeConfigs( } function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig { - // User-level config path (OS-specific) - const userConfigPath = path.join( - getUserConfigDir(), - "opencode", - "oh-my-opencode.json" - ); - - // Project-level config path - const projectConfigPath = path.join( - directory, - ".opencode", - "oh-my-opencode.json" - ); + // User-level config path (OS-specific) - prefer .jsonc over .json + const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode"); + const userDetected = detectConfigFile(userBasePath); + const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json"; + + // Project-level config path - prefer .jsonc over .json + const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode"); + const projectDetected = detectConfigFile(projectBasePath); + const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json"; // Load user config first (base) let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {}; diff --git a/src/shared/index.ts b/src/shared/index.ts index cd74d6c4..ce76682e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -14,3 +14,4 @@ export * from "./config-path" export * from "./data-path" export * from "./config-errors" export * from "./claude-config-dir" +export * from "./jsonc-parser" diff --git a/src/shared/jsonc-parser.test.ts b/src/shared/jsonc-parser.test.ts new file mode 100644 index 00000000..3a6716d3 --- /dev/null +++ b/src/shared/jsonc-parser.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from "bun:test" +import { detectConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +describe("parseJsonc", () => { + test("parses plain JSON", () => { + //#given + const json = `{"key": "value"}` + + //#when + const result = parseJsonc<{ key: string }>(json) + + //#then + expect(result.key).toBe("value") + }) + + test("parses JSONC with line comments", () => { + //#given + const jsonc = `{ + // This is a comment + "key": "value" + }` + + //#when + const result = parseJsonc<{ key: string }>(jsonc) + + //#then + expect(result.key).toBe("value") + }) + + test("parses JSONC with block comments", () => { + //#given + const jsonc = `{ + /* Block comment */ + "key": "value" + }` + + //#when + const result = parseJsonc<{ key: string }>(jsonc) + + //#then + expect(result.key).toBe("value") + }) + + test("parses JSONC with multi-line block comments", () => { + //#given + const jsonc = `{ + /* Multi-line + comment + here */ + "key": "value" + }` + + //#when + const result = parseJsonc<{ key: string }>(jsonc) + + //#then + expect(result.key).toBe("value") + }) + + test("parses JSONC with trailing commas", () => { + //#given + const jsonc = `{ + "key1": "value1", + "key2": "value2", + }` + + //#when + const result = parseJsonc<{ key1: string; key2: string }>(jsonc) + + //#then + expect(result.key1).toBe("value1") + expect(result.key2).toBe("value2") + }) + + test("parses JSONC with trailing comma in array", () => { + //#given + const jsonc = `{ + "arr": [1, 2, 3,] + }` + + //#when + const result = parseJsonc<{ arr: number[] }>(jsonc) + + //#then + expect(result.arr).toEqual([1, 2, 3]) + }) + + test("preserves URLs with // in strings", () => { + //#given + const jsonc = `{ + "url": "https://example.com" + }` + + //#when + const result = parseJsonc<{ url: string }>(jsonc) + + //#then + expect(result.url).toBe("https://example.com") + }) + + test("parses complex JSONC config", () => { + //#given + const jsonc = `{ + // This is an example config + "agents": { + "oracle": { "model": "openai/gpt-5.2" }, // GPT for strategic reasoning + }, + /* Agent overrides */ + "disabled_agents": [], + }` + + //#when + const result = parseJsonc<{ + agents: { oracle: { model: string } } + disabled_agents: string[] + }>(jsonc) + + //#then + expect(result.agents.oracle.model).toBe("openai/gpt-5.2") + expect(result.disabled_agents).toEqual([]) + }) + + test("throws on invalid JSON", () => { + //#given + const invalid = `{ "key": invalid }` + + //#when + //#then + expect(() => parseJsonc(invalid)).toThrow() + }) + + test("throws on unclosed string", () => { + //#given + const invalid = `{ "key": "unclosed }` + + //#when + //#then + expect(() => parseJsonc(invalid)).toThrow() + }) +}) + +describe("parseJsoncSafe", () => { + test("returns data on valid JSONC", () => { + //#given + const jsonc = `{ "key": "value" }` + + //#when + const result = parseJsoncSafe<{ key: string }>(jsonc) + + //#then + expect(result.data).not.toBeNull() + expect(result.data?.key).toBe("value") + expect(result.errors).toHaveLength(0) + }) + + test("returns errors on invalid JSONC", () => { + //#given + const invalid = `{ "key": invalid }` + + //#when + const result = parseJsoncSafe(invalid) + + //#then + expect(result.data).toBeNull() + expect(result.errors.length).toBeGreaterThan(0) + }) +}) + +describe("readJsoncFile", () => { + const testDir = join(__dirname, ".test-jsonc") + const testFile = join(testDir, "config.jsonc") + + test("reads and parses valid JSONC file", () => { + //#given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const content = `{ + // Comment + "test": "value" + }` + writeFileSync(testFile, content) + + //#when + const result = readJsoncFile<{ test: string }>(testFile) + + //#then + expect(result).not.toBeNull() + expect(result?.test).toBe("value") + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("returns null for non-existent file", () => { + //#given + const nonExistent = join(testDir, "does-not-exist.jsonc") + + //#when + const result = readJsoncFile(nonExistent) + + //#then + expect(result).toBeNull() + }) + + test("returns null for malformed JSON", () => { + //#given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + writeFileSync(testFile, "{ invalid }") + + //#when + const result = readJsoncFile(testFile) + + //#then + expect(result).toBeNull() + + rmSync(testDir, { recursive: true, force: true }) + }) +}) + +describe("detectConfigFile", () => { + const testDir = join(__dirname, ".test-detect") + + test("prefers .jsonc over .json", () => { + //#given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + writeFileSync(`${basePath}.json`, "{}") + writeFileSync(`${basePath}.jsonc`, "{}") + + //#when + const result = detectConfigFile(basePath) + + //#then + expect(result.format).toBe("jsonc") + expect(result.path).toBe(`${basePath}.jsonc`) + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("detects .json when .jsonc doesn't exist", () => { + //#given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + writeFileSync(`${basePath}.json`, "{}") + + //#when + const result = detectConfigFile(basePath) + + //#then + expect(result.format).toBe("json") + expect(result.path).toBe(`${basePath}.json`) + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("returns none when neither exists", () => { + //#given + const basePath = join(testDir, "nonexistent") + + //#when + const result = detectConfigFile(basePath) + + //#then + expect(result.format).toBe("none") + }) +}) diff --git a/src/shared/jsonc-parser.ts b/src/shared/jsonc-parser.ts new file mode 100644 index 00000000..c7b2fa74 --- /dev/null +++ b/src/shared/jsonc-parser.ts @@ -0,0 +1,66 @@ +import { existsSync, readFileSync } from "node:fs" +import { parse, ParseError, printParseErrorCode } from "jsonc-parser" + +export interface JsoncParseResult { + data: T | null + errors: Array<{ message: string; offset: number; length: number }> +} + +export function parseJsonc(content: string): T { + const errors: ParseError[] = [] + const result = parse(content, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as T + + if (errors.length > 0) { + const errorMessages = errors + .map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`) + .join(", ") + throw new SyntaxError(`JSONC parse error: ${errorMessages}`) + } + + return result +} + +export function parseJsoncSafe(content: string): JsoncParseResult { + const errors: ParseError[] = [] + const data = parse(content, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as T | null + + return { + data: errors.length > 0 ? null : data, + errors: errors.map((e) => ({ + message: printParseErrorCode(e.error), + offset: e.offset, + length: e.length, + })), + } +} + +export function readJsoncFile(filePath: string): T | null { + try { + const content = readFileSync(filePath, "utf-8") + return parseJsonc(content) + } catch { + return null + } +} + +export function detectConfigFile(basePath: string): { + format: "json" | "jsonc" | "none" + path: string +} { + const jsoncPath = `${basePath}.jsonc` + const jsonPath = `${basePath}.json` + + if (existsSync(jsoncPath)) { + return { format: "jsonc", path: jsoncPath } + } + if (existsSync(jsonPath)) { + return { format: "json", path: jsonPath } + } + return { format: "none", path: jsonPath } +}