Browse Source

feat: add JSONC config file support (#34)

- Add stripJsonComments() to handle single/multi-line comments and trailing commas
- Support opencode.jsonc as alternative to opencode.json
- Preserve existing .jsonc files when reading/writing config
- Warn when writing to .jsonc files (comments will be lost)
- Reuse stripJsonComments in auto-update-checker (DRY)
- Add comprehensive test suite for JSONC parsing
xLillium 2 months ago
parent
commit
ce6b6acb91
3 changed files with 267 additions and 18 deletions
  1. 190 0
      src/cli/config-manager.test.ts
  2. 76 13
      src/cli/config-manager.ts
  3. 1 5
      src/hooks/auto-update-checker/checker.ts

+ 190 - 0
src/cli/config-manager.test.ts

@@ -0,0 +1,190 @@
+import { describe, expect, test } from "bun:test"
+import { stripJsonComments } from "./config-manager"
+
+describe("stripJsonComments", () => {
+  test("returns unchanged JSON without comments", () => {
+    const json = '{"key": "value"}'
+    expect(stripJsonComments(json)).toBe(json)
+  })
+
+  test("strips single-line comments", () => {
+    const json = `{
+  "key": "value" // this is a comment
+}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+  })
+
+  test("strips multi-line comments", () => {
+    const json = `{
+  /* this is a
+     multi-line comment */
+  "key": "value"
+}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+  })
+
+  test("strips trailing commas", () => {
+    const json = `{
+  "key": "value",
+}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+  })
+
+  test("strips trailing commas in arrays", () => {
+    const json = `{
+  "arr": [1, 2, 3,]
+}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ arr: [1, 2, 3] })
+  })
+
+  test("preserves URLs with double slashes", () => {
+    const json = '{"url": "https://example.com"}'
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ url: "https://example.com" })
+  })
+
+  test("preserves strings containing comment-like patterns", () => {
+    const json = '{"code": "// not a comment", "block": "/* also not */"}'
+    expect(JSON.parse(stripJsonComments(json))).toEqual({
+      code: "// not a comment",
+      block: "/* also not */",
+    })
+  })
+
+  test("handles complex JSONC with mixed comments and trailing commas", () => {
+    const json = `{
+  // Configuration for the plugin
+  "plugin": ["oh-my-opencode-slim"],
+  /* Provider settings
+     with multiple lines */
+  "provider": {
+    "google": {
+      "name": "Google", // inline comment
+    },
+  },
+}`
+    const result = JSON.parse(stripJsonComments(json))
+    expect(result).toEqual({
+      plugin: ["oh-my-opencode-slim"],
+      provider: {
+        google: {
+          name: "Google",
+        },
+      },
+    })
+  })
+
+  test("handles escaped quotes in strings", () => {
+    const json = '{"message": "He said \\"hello\\""}'
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ message: 'He said "hello"' })
+  })
+
+  test("handles empty input", () => {
+    expect(stripJsonComments("")).toBe("")
+  })
+
+  test("handles whitespace-only input", () => {
+    expect(stripJsonComments("   ")).toBe("   ")
+  })
+
+  test("handles single-line comment at start of file", () => {
+    const json = `// comment at start
+{"key": "value"}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
+  })
+
+  test("handles comment-only lines between properties", () => {
+    const json = `{
+  "a": 1,
+  // comment line
+  "b": 2
+}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ a: 1, b: 2 })
+  })
+
+  test("handles multiple trailing commas in nested structures", () => {
+    const json = `{"nested": {"a": 1,},}`
+    expect(JSON.parse(stripJsonComments(json))).toEqual({ nested: { a: 1 } })
+  })
+
+  test("handles unclosed string gracefully without throwing", () => {
+    const json = '{"key": "unclosed'
+    expect(() => stripJsonComments(json)).not.toThrow()
+  })
+
+  test("preserves comma-bracket patterns inside strings", () => {
+    const json = '{"script": "test [,]", "json": "{,}"}'
+    const result = JSON.parse(stripJsonComments(json))
+    expect(result.script).toBe("test [,]")
+    expect(result.json).toBe("{,}")
+  })
+
+  test("preserves comma-brace patterns inside strings", () => {
+    const json = '{"glob": "*.{js,ts}", "arr": "[a,]"}'
+    const result = JSON.parse(stripJsonComments(json))
+    expect(result.glob).toBe("*.{js,ts}")
+    expect(result.arr).toBe("[a,]")
+  })
+
+  test("handles Windows CRLF line endings", () => {
+    const json = '{\r\n  "key": "value", // comment\r\n}'
+    const result = JSON.parse(stripJsonComments(json))
+    expect(result).toEqual({ key: "value" })
+  })
+})
+
+describe("JSONC file handling", () => {
+  test("stripJsonComments enables JSONC to be parsed as JSON", () => {
+    const jsoncContent = `{
+  // Single-line comment
+  "plugin": ["oh-my-opencode-slim"],
+  /* Multi-line
+     comment */
+  "provider": {
+    "google": {
+      "name": "Google", // inline comment
+    },
+  },
+}`
+
+    const result = JSON.parse(stripJsonComments(jsoncContent))
+    
+    expect(result).toEqual({
+      plugin: ["oh-my-opencode-slim"],
+      provider: {
+        google: {
+          name: "Google",
+        },
+      },
+    })
+  })
+
+  test("stripJsonComments handles real-world JSONC config format", () => {
+    const jsoncConfig = `{
+  // OpenCode plugin configuration
+  "plugin": [
+    "oh-my-opencode-slim"
+  ],
+  
+  /* Provider settings for Antigravity
+     with model definitions */
+  "provider": {
+    "google": {
+      "name": "Google",
+      "models": ["claude-opus-4-5", "gemini-3-flash"],
+    }
+  },
+  
+  // Server configuration for tmux integration
+  "server": {
+    "port": 4096, // default port
+  }
+}`
+
+    const result = JSON.parse(stripJsonComments(jsoncConfig))
+    
+    expect(result.plugin).toEqual(["oh-my-opencode-slim"])
+    expect(result.provider.google.name).toBe("Google")
+    expect(result.provider.google.models).toEqual(["claude-opus-4-5", "gemini-3-flash"])
+    expect(result.server.port).toBe(4096)
+  })
+})

+ 76 - 13
src/cli/config-manager.ts

@@ -15,6 +15,10 @@ function getConfigJson(): string {
   return join(getConfigDir(), "opencode.json")
 }
 
+function getConfigJsonc(): string {
+  return join(getConfigDir(), "opencode.jsonc")
+}
+
 function getLiteConfig(): string {
   return join(getConfigDir(), "oh-my-opencode-slim.json")
 }
@@ -26,25 +30,84 @@ function ensureConfigDir(): void {
   }
 }
 
+/**
+ * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
+ * Note: When config files are read and written back, any comments will be lost as
+ * JSON.stringify produces standard JSON without comments.
+ */
+export function stripJsonComments(json: string): string {
+  // Regex matches three alternatives (in order):
+  //   1. \\\" - Escaped quotes (preserve these)
+  //   2. \"(?:\\\"|[^\"])*\" - Complete quoted strings (preserve content including // or /*)
+  //   3. (\/\/.*|\/\*[\s\S]*?\*\/) - Single-line or multi-line comments (capture group 1 - strip these)
+  //
+  // The replace callback: if group 1 exists (comment), replace with empty string; otherwise keep match
+  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g
+
+  // Remove trailing commas before closing braces or brackets
+  // Uses same string-aware pattern to avoid corrupting strings containing ,} or ,]
+  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g
+
+  return json
+    .replace(commentPattern, (match, commentGroup) => (commentGroup ? "" : match))
+    .replace(trailingCommaPattern, (match, comma, closing) =>
+      comma ? closing : match
+    )
+}
+
 interface OpenCodeConfig {
   plugin?: string[]
   provider?: Record<string, unknown>
   [key: string]: unknown
 }
 
-function parseConfig(path: string): OpenCodeConfig | null {
+function parseConfigFile(path: string): OpenCodeConfig | null {
   try {
     if (!existsSync(path)) return null
     const stat = statSync(path)
     if (stat.size === 0) return null
     const content = readFileSync(path, "utf-8")
     if (content.trim().length === 0) return null
-    return JSON.parse(content) as OpenCodeConfig
+    return JSON.parse(stripJsonComments(content)) as OpenCodeConfig
   } catch {
     return null
   }
 }
 
+function parseConfig(path: string): OpenCodeConfig | null {
+  const config = parseConfigFile(path)
+  if (config) return config
+
+  if (path.endsWith(".json")) {
+    const jsoncPath = path.replace(/\.json$/, ".jsonc")
+    return parseConfigFile(jsoncPath)
+  }
+  return null
+}
+
+function getExistingConfigPath(): string {
+  const jsonPath = getConfigJson()
+  if (existsSync(jsonPath)) return jsonPath
+  
+  const jsoncPath = getConfigJsonc()
+  if (existsSync(jsoncPath)) return jsoncPath
+  
+  return jsonPath
+}
+
+/**
+ * Write config to file with proper warning if writing to .jsonc file.
+ * Note: Comments in JSONC files will be lost as JSON.stringify produces standard JSON.
+ */
+function writeConfig(configPath: string, config: OpenCodeConfig): void {
+  if (configPath.endsWith(".jsonc")) {
+    console.warn(
+      "[config-manager] Writing to .jsonc file - comments will not be preserved"
+    )
+  }
+  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
+}
+
 export async function isOpenCodeInstalled(): Promise<boolean> {
   try {
     const proc = Bun.spawn(["opencode", "--version"], {
@@ -107,7 +170,7 @@ export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
     }
   }
 
-  const configPath = getConfigJson()
+  const configPath = getExistingConfigPath()
 
   try {
     let config = parseConfig(configPath) ?? {}
@@ -122,7 +185,7 @@ export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
     filteredPlugins.push(PACKAGE_NAME)
     config.plugin = filteredPlugins
 
-    writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
+    writeConfig(configPath, config)
     return { success: true, configPath }
   } catch (err) {
     return {
@@ -134,7 +197,7 @@ export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
 }
 
 export async function addAuthPlugins(installConfig: InstallConfig): Promise<ConfigMergeResult> {
-  const configPath = getConfigJson()
+  const configPath = getExistingConfigPath()
 
   try {
     ensureConfigDir()
@@ -153,7 +216,7 @@ export async function addAuthPlugins(installConfig: InstallConfig): Promise<Conf
     }
 
     config.plugin = plugins
-    writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
+    writeConfig(configPath, config)
     return { success: true, configPath }
   } catch (err) {
     return {
@@ -201,7 +264,7 @@ const GOOGLE_PROVIDER_CONFIG = {
 }
 
 export function addProviderConfig(installConfig: InstallConfig): ConfigMergeResult {
-  const configPath = getConfigJson()
+  const configPath = getExistingConfigPath()
 
   try {
     ensureConfigDir()
@@ -213,7 +276,7 @@ export function addProviderConfig(installConfig: InstallConfig): ConfigMergeResu
       config.provider = providers
     }
 
-    writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
+    writeConfig(configPath, config)
     return { success: true, configPath }
   } catch (err) {
     return {
@@ -228,7 +291,7 @@ export function addProviderConfig(installConfig: InstallConfig): ConfigMergeResu
  * Add server configuration to opencode.json for tmux integration
  */
 export function addServerConfig(installConfig: InstallConfig): ConfigMergeResult {
-  const configPath = getConfigJson()
+  const configPath = getExistingConfigPath()
 
   try {
     ensureConfigDir()
@@ -243,7 +306,7 @@ export function addServerConfig(installConfig: InstallConfig): ConfigMergeResult
       config.server = server
     }
 
-    writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
+    writeConfig(configPath, config)
     return { success: true, configPath }
   } catch (err) {
     return {
@@ -352,7 +415,7 @@ export function writeLiteConfig(installConfig: InstallConfig): ConfigMergeResult
  * Disable OpenCode's default subagents since the plugin provides its own
  */
 export function disableDefaultAgents(): ConfigMergeResult {
-  const configPath = getConfigJson()
+  const configPath = getExistingConfigPath()
 
   try {
     ensureConfigDir()
@@ -363,7 +426,7 @@ export function disableDefaultAgents(): ConfigMergeResult {
     agent.general = { disable: true }
     config.agent = agent
 
-    writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
+    writeConfig(configPath, config)
     return { success: true, configPath }
   } catch (err) {
     return {
@@ -383,7 +446,7 @@ export function detectCurrentConfig(): DetectedConfig {
     hasTmux: false,
   }
 
-  const config = parseConfig(getConfigJson())
+  const config = parseConfig(getExistingConfigPath())
   if (!config) return result
 
   const plugins = config.plugin ?? []

+ 1 - 5
src/hooks/auto-update-checker/checker.ts

@@ -13,6 +13,7 @@ import {
   USER_CONFIG_DIR,
 } from "./constants"
 import { log } from "../../shared/logger"
+import { stripJsonComments } from "../../cli/config-manager"
 
 function isPrereleaseVersion(version: string): boolean {
   return version.includes("-")
@@ -38,11 +39,6 @@ export function extractChannel(version: string | null): string {
   return "latest"
 }
 
-function stripJsonComments(json: string): string {
-  return json
-    .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, g) => (g ? "" : m))
-    .replace(/,(\s*[}\]])/g, "$1")
-}
 
 function getConfigPaths(directory: string): string[] {
   const paths = [