Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dependencies": {
"@opencode-ai/plugin": "1.1.23",
"bun-pty": "^0.4.5",
"jsonc-parser": "^3.3.1",
"valibot": "^1.2.0",
"yaml": "^2.8.2"
},
Expand Down
55 changes: 53 additions & 2 deletions src/config-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// src/config-loader.test.ts
import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { type MicodeConfig, type ProviderInfo, validateAgentModels } from "./config-loader";
import { loadMicodeConfig, type MicodeConfig, type ProviderInfo, validateAgentModels } from "./config-loader";

// Helper to create a minimal ProviderInfo for testing
function createProvider(id: string, modelIds: string[]): ProviderInfo {
Expand Down Expand Up @@ -224,3 +227,51 @@ describe("validateAgentModels", () => {
expect(result).toEqual({ agents: {} });
});
});

describe("JSONC parsing via loadMicodeConfig", () => {
let testConfigDir: string;

beforeEach(() => {
testConfigDir = join(tmpdir(), `micode-jsonc-unit-test-${Date.now()}`);
mkdirSync(testConfigDir, { recursive: true });
});

afterEach(() => {
rmSync(testConfigDir, { recursive: true, force: true });
});

test("parses .jsonc file with comments and trailing commas", async () => {
writeFileSync(
join(testConfigDir, "micode.jsonc"),
`{
// Agent configuration
"agents": {
"commander": {
"model": "openai/gpt-4o", // use GPT-4o
"temperature": 0.2,
},
},
}`,
);

const config = await loadMicodeConfig(testConfigDir);

expect(config).not.toBeNull();
expect(config?.agents?.commander?.model).toBe("openai/gpt-4o");
expect(config?.agents?.commander?.temperature).toBe(0.2);
});

test("still parses plain .json files (backward compatibility)", async () => {
writeFileSync(
join(testConfigDir, "micode.json"),
JSON.stringify({
agents: { commander: { model: "openai/gpt-4o" } },
}),
);

const config = await loadMicodeConfig(testConfigDir);

expect(config).not.toBeNull();
expect(config?.agents?.commander?.model).toBe("openai/gpt-4o");
});
});
84 changes: 70 additions & 14 deletions src/config-loader.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// src/config-loader.ts
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

import type { AgentConfig } from "@opencode-ai/sdk";
import { type ParseError, parse as parseJsonc } from "jsonc-parser";

// Minimal type for provider validation - only what we need
export interface ProviderInfo {
Expand All @@ -21,23 +22,75 @@ interface OpencodeConfig {
}

/**
* Load opencode.json config file (synchronous)
* Parse a JSON or JSONC string, supporting comments and trailing commas.
* Uses the same options as OpenCode's own parser.
*/
function parseConfigJson(content: string): unknown {
const errors: ParseError[] = [];
const result = parseJsonc(content, errors, { allowTrailingComma: true });
if (errors.length > 0) {
throw new Error(`Invalid JSON/JSONC: ${errors.length} parse error(s)`);
}
return result;
}

/**
* Resolve a config file path, preferring .jsonc over .json (synchronous).
* Returns the path to the first file found, or null if neither exists.
*/
function resolveConfigFileSync(baseDir: string, baseName: string): string | null {
const jsoncPath = join(baseDir, `${baseName}.jsonc`);
if (existsSync(jsoncPath)) {
return jsoncPath;
}

const jsonPath = join(baseDir, `${baseName}.json`);
if (existsSync(jsonPath)) {
return jsonPath;
}

return null;
}

/**
* Read a config file, preferring .jsonc over .json (async).
* Returns the file content string, or null if neither file exists.
*/
async function readConfigFileAsync(baseDir: string, baseName: string): Promise<string | null> {
// Try .jsonc first
try {
return await readFile(join(baseDir, `${baseName}.jsonc`), "utf-8");
} catch {
// .jsonc not found, try .json
}

try {
return await readFile(join(baseDir, `${baseName}.json`), "utf-8");
} catch {
return null;
}
}

/**
* Load opencode.json/opencode.jsonc config file (synchronous)
* Returns the parsed config or null if unavailable
*/
function loadOpencodeConfig(configDir?: string): OpencodeConfig | null {
const baseDir = configDir ?? join(homedir(), ".config", "opencode");

try {
const configPath = join(baseDir, "opencode.json");
const configPath = resolveConfigFileSync(baseDir, "opencode");
if (!configPath) return null;

const content = readFileSync(configPath, "utf-8");
return JSON.parse(content) as OpencodeConfig;
return parseConfigJson(content) as OpencodeConfig;
} catch {
return null;
}
}

/**
* Load available models from opencode.json config file (synchronous)
* Load available models from opencode.json/opencode.jsonc config file (synchronous)
* Returns a Set of "provider/model" strings
*/
export function loadAvailableModels(configDir?: string): Set<string> {
Expand All @@ -58,7 +111,7 @@ export function loadAvailableModels(configDir?: string): Set<string> {
}

/**
* Load the default model from opencode.json config file (synchronous)
* Load the default model from opencode.json/opencode.jsonc config file (synchronous)
* Returns the model string in "provider/model" format or null if not set
*/
export function loadDefaultModel(configDir?: string): string | null {
Expand Down Expand Up @@ -90,17 +143,18 @@ export interface MicodeConfig {
}

/**
* Load micode.json from ~/.config/opencode/micode.json
* Returns null if file doesn't exist or is invalid JSON
* Load micode.json/micode.jsonc from ~/.config/opencode/
* Returns null if file doesn't exist or is invalid
* @param configDir - Optional override for config directory (for testing)
*/
export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig | null> {
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
const configPath = join(baseDir, "micode.json");

try {
const content = await readFile(configPath, "utf-8");
const parsed = JSON.parse(content) as Record<string, unknown>;
const content = await readConfigFileAsync(baseDir, "micode");
if (!content) return null;

const parsed = parseConfigJson(content) as Record<string, unknown>;

const result: MicodeConfig = {};

Expand Down Expand Up @@ -166,17 +220,19 @@ export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig
}

/**
* Load model context limits from opencode.json
* Load model context limits from opencode.json/opencode.jsonc
* Returns a Map of "provider/model" -> context limit (tokens)
*/
export function loadModelContextLimits(configDir?: string): Map<string, number> {
const limits = new Map<string, number>();
const baseDir = configDir ?? join(homedir(), ".config", "opencode");

try {
const configPath = join(baseDir, "opencode.json");
const configPath = resolveConfigFileSync(baseDir, "opencode");
if (!configPath) return limits;

const content = readFileSync(configPath, "utf-8");
const config = JSON.parse(content) as {
const config = parseConfigJson(content) as {
provider?: Record<string, { models?: Record<string, { limit?: { context?: number } }> }>;
};

Expand Down
Loading