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:
30
README.md
30
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).
|
||||
|
||||
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ?? {}
|
||||
|
||||
|
||||
24
src/index.ts
24
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<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) ?? {};
|
||||
|
||||
@@ -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"
|
||||
|
||||
266
src/shared/jsonc-parser.test.ts
Normal file
266
src/shared/jsonc-parser.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
66
src/shared/jsonc-parser.ts
Normal file
66
src/shared/jsonc-parser.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user