feat: Add JSONC support for oh-my-opencode config files (#275)

Uses Microsoft's jsonc-parser package for reliable JSONC parsing:
- oh-my-opencode.jsonc (preferred) or oh-my-opencode.json
- Supports line comments (//), block comments (/* */), and trailing commas
- Better error reporting with line/column positions

Core changes:
- Added jsonc-parser dependency (Microsoft's VS Code parser)
- Shared JSONC utilities (parseJsonc, parseJsoncSafe, readJsoncFile, detectConfigFile)
- Main plugin config loader uses detectConfigFile for .jsonc priority
- CLI config manager supports JSONC parsing

Comprehensive test suite with 18 tests for JSONC parsing.

Fixes #265

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Sisyphus
2025-12-28 00:45:17 +09:00
committed by GitHub
parent c4c0d82f97
commit b2c2c6eab7
8 changed files with 381 additions and 88 deletions

View File

@@ -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).

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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<OpenCodeConfig>(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<string, unknown>
const existing = parseJsonc<Record<string, unknown>>(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<OmoConfigData>(content)
const agents = omoConfig.agents ?? {}

View File

@@ -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<Record<string, unknown>>(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"
);
// 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
const projectConfigPath = path.join(
directory,
".opencode",
"oh-my-opencode.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) ?? {};

View File

@@ -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"

View File

@@ -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")
})
})

View File

@@ -0,0 +1,66 @@
import { existsSync, readFileSync } from "node:fs"
import { parse, ParseError, printParseErrorCode } from "jsonc-parser"
export interface JsoncParseResult<T> {
data: T | null
errors: Array<{ message: string; offset: number; length: number }>
}
export function parseJsonc<T = unknown>(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<T = unknown>(content: string): JsoncParseResult<T> {
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<T = unknown>(filePath: string): T | null {
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<T>(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 }
}