Browse Source

feat: add JSONC support for configuration files (#113)

- Add support for oh-my-opencode-slim.jsonc format (JSON with Comments)
- Support single-line (//) and multi-line (/* */) comments
- Support trailing commas in arrays and objects
- Prefer .jsonc over .json when both exist
- Maintain full backward compatibility with .json files
- Update documentation across all docs files
- Add comprehensive test coverage for JSONC parsing

Closes #<issue_number>
Gabriel Rodrigues 2 months ago
parent
commit
04660d267d
7 changed files with 287 additions and 28 deletions
  1. 1 1
      README.md
  2. 2 2
      docs/antigravity.md
  3. 6 6
      docs/installation.md
  4. 44 7
      docs/quick-reference.md
  5. 3 3
      docs/tmux-integration.md
  6. 186 0
      src/config/loader.test.ts
  7. 45 9
      src/config/loader.ts

+ 1 - 1
README.md

@@ -22,7 +22,7 @@ opencode auth login
 
 Run `ping all agents` to verify everything works.
 
-> **💡 Models are fully customizable.** Edit `~/.config/opencode/oh-my-opencode-slim.json` to assign any model to any agent.
+> **💡 Models are fully customizable.** Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc` for comments support) to assign any model to any agent.
 
 ### For LLM Agents
 

+ 2 - 2
docs/antigravity.md

@@ -114,7 +114,7 @@ Edit `~/.config/opencode/opencode.json` and add the "google" provider configurat
 
 ### Step 2: Configure Agent Models
 
-Edit `~/.config/opencode/oh-my-opencode-slim.json` and add the Antigravity preset:
+Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`) and add the Antigravity preset:
 
 ```json
 {
@@ -368,7 +368,7 @@ To test different configurations:
 export OH_MY_OPENCODE_SLIM_PRESET=openai
 opencode
 
-# Or edit ~/.config/opencode/oh-my-opencode-slim.json
+# Or edit ~/.config/opencode/oh-my-opencode-slim.json (or .jsonc)
 # Change the "preset" field and restart OpenCode
 ```
 

+ 6 - 6
docs/installation.md

@@ -39,7 +39,7 @@ opencode auth login
 
 Once authenticated, run opencode and `ping all agents` to verify all agents respond.
 
-> **💡 Tip: Models are fully customizable.** The installer sets sensible defaults, but you can assign *any* model to *any* agent. Edit `~/.config/opencode/oh-my-opencode-slim.json` to override models, adjust reasoning effort, or disable agents entirely. See [Configuration](quick-reference.md#configuration) for details.
+> **💡 Tip: Models are fully customizable.** The installer sets sensible defaults, but you can assign *any* model to *any* agent. Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc` for comments support) to override models, adjust reasoning effort, or disable agents entirely. See [Configuration](quick-reference.md#configuration) for details.
 
 ### Alternative: Ask Any Coding Agent
 
@@ -74,7 +74,7 @@ Ask these questions **one at a time**, waiting for responses:
 Help the user understand the tradeoffs:
 - Kimi For Coding provides powerful k1.5 models for coding tasks.
 - OpenAI is optional; it enables `openai/` models.
-- If the user has **no providers**, the plugin still works using **OpenCode Zen** free models (`opencode/big-pickle`). They can switch to paid providers later by editing `~/.config/opencode/oh-my-opencode-slim.json`.
+- If the user has **no providers**, the plugin still works using **OpenCode Zen** free models (`opencode/big-pickle`). They can switch to paid providers later by editing `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`).
 
 ### Step 3: Run the Installer
 
@@ -98,12 +98,12 @@ bunx oh-my-opencode-slim@latest install --no-tui --kimi=no --openai=no --tmux=no
 
 The installer automatically:
 - Adds the plugin to `~/.config/opencode/opencode.json`
-- Generates agent model mappings in `~/.config/opencode/oh-my-opencode-slim.json`
+- Generates agent model mappings in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`)
 
 **Crucial Advice for the User:**
-- They can easily assign **different models to different agents** by editing `~/.config/opencode/oh-my-opencode-slim.json`.
+- They can easily assign **different models to different agents** by editing `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`).
 - If they add a new provider later, they just need to update this file.
-- Read generated `~/.config/opencode/oh-my-opencode-slim.json` file and report the model configuration.
+- Read generated `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`) file and report the model configuration.
 
 ### Step 4: Authenticate with Providers
 
@@ -135,7 +135,7 @@ bunx oh-my-opencode-slim@latest install --help
 ```
 
 Then manually create the config files at:
-- `~/.config/opencode/oh-my-opencode-slim.json`
+- `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`)
 
 ### Agents Not Responding
 

+ 44 - 7
docs/quick-reference.md

@@ -21,7 +21,7 @@ Presets are pre-configured agent model mappings for different provider combinati
 
 **Method 1: Edit Config File**
 
-Edit `~/.config/opencode/oh-my-opencode-slim.json` and change the `preset` field:
+Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`) and change the `preset` field:
 
 ```json
 {
@@ -229,7 +229,7 @@ python3 ~/.config/opencode/skills/cartography/scripts/cartographer.py update --r
 
 ### Skills Assignment
 
-You can customize which skills each agent is allowed to use in `~/.config/opencode/oh-my-opencode-slim.json`.
+You can customize which skills each agent is allowed to use in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`).
 
 **Syntax:**
 
@@ -290,7 +290,7 @@ Control which agents can access which MCP servers using per-agent allowlists:
 
 ### Configuration & Syntax
 
-You can configure MCP access in your plugin configuration file: `~/.config/opencode/oh-my-opencode-slim.json`.
+You can configure MCP access in your plugin configuration file: `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`).
 
 **Per-Agent Permissions**
 
@@ -345,7 +345,7 @@ You can disable specific MCP servers globally by adding them to the `disabled_mc
 
 #### Quick Setup
 
-1. **Enable tmux integration** in `oh-my-opencode-slim.json`:
+1. **Enable tmux integration** in `oh-my-opencode-slim.json` (or `.jsonc`):
 
    ```json
    {
@@ -423,8 +423,10 @@ OpenCode automatically formats files after they're written or edited using langu
 | File | Purpose |
 |------|---------|
 | `~/.config/opencode/opencode.json` | OpenCode core settings |
-| `~/.config/opencode/oh-my-opencode-slim.json` | Plugin settings (agents, tmux, MCPs) |
-| `.opencode/oh-my-opencode-slim.json` | Project-local plugin overrides (optional) |
+| `~/.config/opencode/oh-my-opencode-slim.json` or `.jsonc` | Plugin settings (agents, tmux, MCPs) |
+| `.opencode/oh-my-opencode-slim.json` or `.jsonc` | Project-local plugin overrides (optional) |
+
+> **💡 JSONC Support:** Configuration files support JSONC format (JSON with Comments). Use `.jsonc` extension to enable comments and trailing commas. If both `.jsonc` and `.json` exist, `.jsonc` takes precedence.
 
 ### Prompt Overriding
 
@@ -455,7 +457,42 @@ You can customize agent prompts by creating markdown files in `~/.config/opencod
 
 This allows you to fine-tune agent behavior without modifying the source code.
 
-### Plugin Config (`oh-my-opencode-slim.json`)
+### JSONC Format (JSON with Comments)
+
+The plugin supports **JSONC** format for configuration files, allowing you to:
+
+- Add single-line comments (`//`)
+- Add multi-line comments (`/* */`)
+- Use trailing commas in arrays and objects
+
+**File Priority:**
+1. `oh-my-opencode-slim.jsonc` (preferred if exists)
+2. `oh-my-opencode-slim.json` (fallback)
+
+**Example JSONC Configuration:**
+
+```jsonc
+{
+  // Use preset for development
+  "preset": "dev",
+
+  /* Presets definition - customize agent models here */
+  "presets": {
+    "dev": {
+      // Fast models for quick iteration
+      "oracle": { "model": "google/gemini-3-flash" },
+      "explorer": { "model": "google/gemini-3-flash" },
+    },
+  },
+
+  "tmux": {
+    "enabled": true,  // Enable for monitoring
+    "layout": "main-vertical",
+  },
+}
+```
+
+### Plugin Config (`oh-my-opencode-slim.json` or `oh-my-opencode-slim.jsonc`)
 
 The installer generates this file based on your providers. You can manually customize it to mix and match models. See the [Presets](#presets) section for detailed configuration options.
 

+ 3 - 3
docs/tmux-integration.md

@@ -34,7 +34,7 @@ Complete guide for using tmux integration with oh-my-opencode-slim to watch agen
 
 ### Step 1: Enable Tmux Integration
 
-Edit `~/.config/opencode/oh-my-opencode-slim.json`:
+Edit `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 
 ```json
 {
@@ -64,7 +64,7 @@ That's it! Your agents will now spawn panes automatically.
 
 ### Tmux Settings
 
-Configure tmux behavior in `~/.config/opencode/oh-my-opencode-slim.json`:
+Configure tmux behavior in `~/.config/opencode/oh-my-opencode-slim.json` (or `.jsonc`):
 
 ```json
 {
@@ -196,7 +196,7 @@ tmux switch -t project2
 **Solutions:**
 1. **Verify tmux integration is enabled:**
    ```bash
-   cat ~/.config/opencode/oh-my-opencode-slim.json | grep tmux
+    cat ~/.config/opencode/oh-my-opencode-slim.json | grep tmux # (or .jsonc)
    ```
 
 2. **Check port configuration:**

+ 186 - 0
src/config/loader.test.ts

@@ -562,6 +562,192 @@ describe('environment variable preset override', () => {
   });
 });
 
+describe('JSONC config support', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonc-test-'));
+    originalEnv = { ...process.env };
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('loads .jsonc file with single-line comments', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // This is a comment
+        "agents": {
+          "oracle": { "model": "test/model" } // inline comment
+        }
+      }`,
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('test/model');
+  });
+
+  test('loads .jsonc file with multi-line comments', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        /* Multi-line
+           comment block */
+        "agents": {
+          "explorer": { "model": "explorer-model" }
+        }
+      }`,
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.explorer?.model).toBe('explorer-model');
+  });
+
+  test('loads .jsonc file with trailing commas', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        "agents": {
+          "oracle": { "model": "test-model", },
+        },
+      }`,
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('test-model');
+  });
+
+  test('prefers .jsonc over .json when both exist', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+
+    // Create both files
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
+    );
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // JSONC version
+        "agents": { "oracle": { "model": "jsonc-model" } }
+      }`,
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('jsonc-model');
+  });
+
+  test('falls back to .json when .jsonc does not exist', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+
+    // Only create .json file
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('json-model');
+  });
+
+  test('loads user config from .jsonc', () => {
+    const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // User config with comments
+        "agents": { "librarian": { "model": "user-librarian" } }
+      }`,
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.librarian?.model).toBe('user-librarian');
+  });
+
+  test('merges user .jsonc with project .jsonc', () => {
+    const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // User config
+        "agents": {
+          "oracle": { "model": "user-oracle", "temperature": 0.5 }
+        }
+      }`,
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // Project config
+        "agents": { "oracle": { "temperature": 0.8 } }
+      }`,
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('user-oracle');
+    expect(config.agents?.oracle?.temperature).toBe(0.8);
+  });
+
+  test('handles complex JSONC with mixed comments and trailing commas', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // Main configuration
+        "preset": "dev",
+        /* Presets definition */
+        "presets": {
+          "dev": {
+            // Development agents
+            "oracle": { "model": "dev-oracle", },
+            "explorer": { "model": "dev-explorer", },
+          },
+        },
+        "tmux": {
+          "enabled": true, // Enable tmux
+          "layout": "main-vertical",
+        },
+      }`,
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.preset).toBe('dev');
+    expect(config.agents?.oracle?.model).toBe('dev-oracle');
+    expect(config.agents?.explorer?.model).toBe('dev-explorer');
+    expect(config.tmux?.enabled).toBe(true);
+    expect(config.tmux?.layout).toBe('main-vertical');
+  });
+});
+
 describe('loadAgentPrompt', () => {
   let tempDir: string;
   let originalEnv: typeof process.env;

+ 45 - 9
src/config/loader.ts

@@ -1,9 +1,9 @@
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
+import { stripJsonComments } from '../cli/config-io';
 import { type PluginConfig, PluginConfigSchema } from './schema';
 
-const CONFIG_FILENAME = 'oh-my-opencode-slim.json';
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
 
 /**
@@ -18,6 +18,7 @@ function getUserConfigDir(): string {
 
 /**
  * Load and validate plugin configuration from a specific file path.
+ * Supports both .json and .jsonc formats (JSON with comments).
  * Returns null if the file doesn't exist, is invalid, or cannot be read.
  * Logs warnings for validation errors and unexpected read errors.
  *
@@ -27,7 +28,8 @@ function getUserConfigDir(): string {
 function loadConfigFromPath(configPath: string): PluginConfig | null {
   try {
     const content = fs.readFileSync(configPath, 'utf-8');
-    const rawConfig = JSON.parse(content);
+    // Use stripJsonComments to support JSONC format (comments and trailing commas)
+    const rawConfig = JSON.parse(stripJsonComments(content));
     const result = PluginConfigSchema.safeParse(rawConfig);
 
     if (!result.success) {
@@ -54,6 +56,27 @@ function loadConfigFromPath(configPath: string): PluginConfig | null {
 }
 
 /**
+ * Find existing config file path, preferring .jsonc over .json.
+ * Checks for .jsonc first, then falls back to .json.
+ *
+ * @param basePath - Base path without extension (e.g., /path/to/oh-my-opencode-slim)
+ * @returns Path to existing config file, or null if neither exists
+ */
+function findConfigPath(basePath: string): string | null {
+  const jsoncPath = `${basePath}.jsonc`;
+  const jsonPath = `${basePath}.json`;
+
+  // Prefer .jsonc over .json
+  if (fs.existsSync(jsoncPath)) {
+    return jsoncPath;
+  }
+  if (fs.existsSync(jsonPath)) {
+    return jsonPath;
+  }
+  return null;
+}
+
+/**
  * Recursively merge two objects, with override values taking precedence.
  * For nested objects, merges recursively. For arrays and primitives, override replaces base.
  *
@@ -96,9 +119,10 @@ function deepMerge<T extends Record<string, unknown>>(
  * Load plugin configuration from user and project config files, merging them appropriately.
  *
  * Configuration is loaded from two locations:
- * 1. User config: ~/.config/opencode/oh-my-opencode-slim.json (or $XDG_CONFIG_HOME)
- * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.json
+ * 1. User config: ~/.config/opencode/oh-my-opencode-slim.jsonc or .json (or $XDG_CONFIG_HOME)
+ * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.jsonc or .json
  *
+ * JSONC format is preferred over JSON (allows comments and trailing commas).
  * Project config takes precedence over user config. Nested objects (agents, tmux) are
  * deep-merged, while top-level arrays are replaced entirely by project config.
  *
@@ -106,17 +130,29 @@ function deepMerge<T extends Record<string, unknown>>(
  * @returns Merged plugin configuration (empty object if no configs found)
  */
 export function loadPluginConfig(directory: string): PluginConfig {
-  const userConfigPath = path.join(
+  const userConfigBasePath = path.join(
     getUserConfigDir(),
     'opencode',
-    CONFIG_FILENAME,
+    'oh-my-opencode-slim',
+  );
+
+  const projectConfigBasePath = path.join(
+    directory,
+    '.opencode',
+    'oh-my-opencode-slim',
   );
 
-  const projectConfigPath = path.join(directory, '.opencode', CONFIG_FILENAME);
+  // Find existing config files (preferring .jsonc over .json)
+  const userConfigPath = findConfigPath(userConfigBasePath);
+  const projectConfigPath = findConfigPath(projectConfigBasePath);
 
-  let config: PluginConfig = loadConfigFromPath(userConfigPath) ?? {};
+  let config: PluginConfig = userConfigPath
+    ? (loadConfigFromPath(userConfigPath) ?? {})
+    : {};
 
-  const projectConfig = loadConfigFromPath(projectConfigPath);
+  const projectConfig = projectConfigPath
+    ? loadConfigFromPath(projectConfigPath)
+    : null;
   if (projectConfig) {
     config = {
       ...config,