From 6fc5ff5f68e2155c769fe80c2d4ec8718bb5c587 Mon Sep 17 00:00:00 2001 From: "youming.tang" Date: Thu, 12 Feb 2026 13:49:09 +0900 Subject: [PATCH] feat(config): add TOML config file support Add support for TOML configuration files as an alternative to JSON/JSONC. Config files are now detected with priority: .jsonc > .json > .toml Changes: - Add smol-toml dependency for TOML parsing - Create toml-parser.ts with parseToml, parseTomlSafe, readTomlFile - Create config-detector.ts with unified detectConfigFile and parseConfigContent - Update plugin-config.ts to support TOML format - Update doctor checks to validate TOML configs - Fix migrateConfigFile to skip file write for TOML (in-memory only) Closes #1782 --- bun.lock | 3 + package.json | 1 + src/cli/doctor/checks/config.ts | 16 +- .../doctor/checks/model-resolution-config.ts | 25 +- src/cli/doctor/types.ts | 2 +- src/plugin-config.ts | 19 +- src/shared/config-detector.test.ts | 118 +++++++++ src/shared/config-detector.ts | 31 +++ src/shared/index.ts | 2 + src/shared/jsonc-parser.test.ts | 3 +- src/shared/jsonc-parser.ts | 18 +- src/shared/migration.ts | 2 +- src/shared/migration/config-migration.ts | 21 +- src/shared/toml-parser.test.ts | 247 ++++++++++++++++++ src/shared/toml-parser.ts | 44 ++++ 15 files changed, 499 insertions(+), 53 deletions(-) create mode 100644 src/shared/config-detector.test.ts create mode 100644 src/shared/config-detector.ts create mode 100644 src/shared/toml-parser.test.ts create mode 100644 src/shared/toml-parser.ts diff --git a/bun.lock b/bun.lock index 0175f65a56..c723ce9548 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "picomatch": "^4.0.2", + "smol-toml": "^1.6.0", "vscode-jsonrpc": "^8.2.0", "zod": "^4.1.8", }, @@ -290,6 +291,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], diff --git a/package.json b/package.json index a74c99a45c..d179f8291d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "picomatch": "^4.0.2", + "smol-toml": "^1.6.0", "vscode-jsonrpc": "^8.2.0", "zod": "^4.1.8" }, diff --git a/src/cli/doctor/checks/config.ts b/src/cli/doctor/checks/config.ts index c2adc670e7..f399a8acb6 100644 --- a/src/cli/doctor/checks/config.ts +++ b/src/cli/doctor/checks/config.ts @@ -2,31 +2,33 @@ import { existsSync, readFileSync } from "node:fs" import { join } from "node:path" import type { CheckResult, CheckDefinition, ConfigInfo } from "../types" import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" -import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared" +import { parseConfigContent, detectConfigFile, getOpenCodeConfigDir, type ConfigFormat } from "../../../shared" import { OhMyOpenCodeConfigSchema } from "../../../config" +type DetectedConfigFormat = Exclude + const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`) const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) -function findConfigPath(): { path: string; format: "json" | "jsonc" } | null { +function findConfigPath(): { path: string; format: DetectedConfigFormat } | null { const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) if (projectDetected.format !== "none") { - return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" } + return { path: projectDetected.path, format: projectDetected.format as DetectedConfigFormat } } const userDetected = detectConfigFile(USER_CONFIG_BASE) if (userDetected.format !== "none") { - return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" } + return { path: userDetected.path, format: userDetected.format as DetectedConfigFormat } } return null } -export function validateConfig(configPath: string): { valid: boolean; errors: string[] } { +export function validateConfig(configPath: string, format: DetectedConfigFormat): { valid: boolean; errors: string[] } { try { const content = readFileSync(configPath, "utf-8") - const rawConfig = parseJsonc>(content) + const rawConfig = parseConfigContent(content, format) const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) if (!result.success) { @@ -68,7 +70,7 @@ export function getConfigInfo(): ConfigInfo { } } - const validation = validateConfig(configPath.path) + const validation = validateConfig(configPath.path, configPath.format) return { exists: true, diff --git a/src/cli/doctor/checks/model-resolution-config.ts b/src/cli/doctor/checks/model-resolution-config.ts index db01cc4e5a..d4e24d337e 100644 --- a/src/cli/doctor/checks/model-resolution-config.ts +++ b/src/cli/doctor/checks/model-resolution-config.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs" import { join } from "node:path" -import { detectConfigFile, getOpenCodeConfigPaths, parseJsonc } from "../../../shared" +import { detectConfigFile, getOpenCodeConfigPaths, parseConfigContent, type ConfigFormat } from "../../../shared" import type { OmoConfig } from "./model-resolution-types" const PACKAGE_NAME = "oh-my-opencode" @@ -10,25 +10,24 @@ const USER_CONFIG_BASE = join( ) const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) +function loadConfigFromPath(path: string, format: Exclude): OmoConfig | null { + try { + const content = readFileSync(path, "utf-8") + return parseConfigContent(content, format) + } catch { + return null + } +} + export function loadOmoConfig(): OmoConfig | null { const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) if (projectDetected.format !== "none") { - try { - const content = readFileSync(projectDetected.path, "utf-8") - return parseJsonc(content) - } catch { - return null - } + return loadConfigFromPath(projectDetected.path, projectDetected.format as Exclude) } const userDetected = detectConfigFile(USER_CONFIG_BASE) if (userDetected.format !== "none") { - try { - const content = readFileSync(userDetected.path, "utf-8") - return parseJsonc(content) - } catch { - return null - } + return loadConfigFromPath(userDetected.path, userDetected.format as Exclude) } return null diff --git a/src/cli/doctor/types.ts b/src/cli/doctor/types.ts index b512c6de49..de2c50d5fd 100644 --- a/src/cli/doctor/types.ts +++ b/src/cli/doctor/types.ts @@ -65,7 +65,7 @@ export interface PluginInfo { export interface ConfigInfo { exists: boolean path: string | null - format: "json" | "jsonc" | null + format: "json" | "jsonc" | "toml" | null valid: boolean errors: string[] } diff --git a/src/plugin-config.ts b/src/plugin-config.ts index b95942fd4c..abc53235e6 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -6,21 +6,24 @@ import { deepMerge, getOpenCodeConfigDir, addConfigLoadError, - parseJsonc, detectConfigFile, + type ConfigFormat, + parseConfigContent, migrateConfigFile, + type ConfigFileFormat, } from "./shared"; export function loadConfigFromPath( configPath: string, + format: ConfigFormat, ctx: unknown ): OhMyOpenCodeConfig | null { try { if (fs.existsSync(configPath)) { const content = fs.readFileSync(configPath, "utf-8"); - const rawConfig = parseJsonc>(content); + const rawConfig = parseConfigContent(content, format as Exclude); - migrateConfigFile(configPath, rawConfig); + migrateConfigFile(configPath, rawConfig, format as ConfigFileFormat); const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig); @@ -94,7 +97,7 @@ export function loadPluginConfig( directory: string, ctx: unknown ): OhMyOpenCodeConfig { - // User-level config path - prefer .jsonc over .json + // User-level config path - prefer .jsonc > .json > .toml const configDir = getOpenCodeConfigDir({ binary: "opencode" }); const userBasePath = path.join(configDir, "oh-my-opencode"); const userDetected = detectConfigFile(userBasePath); @@ -102,21 +105,23 @@ export function loadPluginConfig( userDetected.format !== "none" ? userDetected.path : userBasePath + ".json"; + const userConfigFormat = userDetected.format !== "none" ? userDetected.format : "json"; - // Project-level config path - prefer .jsonc over .json + // Project-level config path - prefer .jsonc > .json > .toml const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode"); const projectDetected = detectConfigFile(projectBasePath); const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json"; + const projectConfigFormat = projectDetected.format !== "none" ? projectDetected.format : "json"; // Load user config first (base) let config: OhMyOpenCodeConfig = - loadConfigFromPath(userConfigPath, ctx) ?? {}; + loadConfigFromPath(userConfigPath, userConfigFormat, ctx) ?? {}; // Override with project config - const projectConfig = loadConfigFromPath(projectConfigPath, ctx); + const projectConfig = loadConfigFromPath(projectConfigPath, projectConfigFormat, ctx); if (projectConfig) { config = mergeConfigs(config, projectConfig); } diff --git a/src/shared/config-detector.test.ts b/src/shared/config-detector.test.ts new file mode 100644 index 0000000000..135eb71bc9 --- /dev/null +++ b/src/shared/config-detector.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { detectConfigFile, type ConfigFormat } from "./config-detector" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +describe("detectConfigFile", () => { + const testDir = join(__dirname, ".test-config-detect") + + test("prefers .jsonc over .json and .toml", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + writeFileSync(`${basePath}.json`, "{}") + writeFileSync(`${basePath}.jsonc`, "{}") + writeFileSync(`${basePath}.toml`, "key = \"value\"") + + // when + const result = detectConfigFile(basePath) + + // then + expect(result.format).toBe("jsonc") + expect(result.path).toBe(`${basePath}.jsonc`) + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("prefers .json over .toml when .jsonc doesn't exist", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + writeFileSync(`${basePath}.json`, "{}") + writeFileSync(`${basePath}.toml`, "key = \"value\"") + + // when + const result = detectConfigFile(basePath) + + // then + expect(result.format).toBe("json") + expect(result.path).toBe(`${basePath}.json`) + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("detects .toml when only .toml exists", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + writeFileSync(`${basePath}.toml`, "key = \"value\"") + + // when + const result = detectConfigFile(basePath) + + // then + expect(result.format).toBe("toml") + expect(result.path).toBe(`${basePath}.toml`) + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("detects .jsonc when only .jsonc exists", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + 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 only .json exists", () => { + // 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 no config files exist", () => { + // given + const basePath = join(testDir, "nonexistent") + + // when + const result = detectConfigFile(basePath) + + // then + expect(result.format).toBe("none") + expect(result.path).toBe(`${basePath}.json`) + }) + + test("returns correct path type for jsonc", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const basePath = join(testDir, "config") + writeFileSync(`${basePath}.jsonc`, "{}") + + // when + const result = detectConfigFile(basePath) + + // then + expect(result.format).toBe("jsonc" as ConfigFormat) + + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/config-detector.ts b/src/shared/config-detector.ts new file mode 100644 index 0000000000..2fe052fa16 --- /dev/null +++ b/src/shared/config-detector.ts @@ -0,0 +1,31 @@ +import { existsSync } from "node:fs" +import { parseJsonc } from "./jsonc-parser" +import { parseToml } from "./toml-parser" + +export type ConfigFormat = "json" | "jsonc" | "toml" | "none" + +const CONFIG_EXTENSIONS = ["jsonc", "json", "toml"] as const + +export function detectConfigFile(basePath: string): { + format: ConfigFormat + path: string +} { + for (const ext of CONFIG_EXTENSIONS) { + const path = `${basePath}.${ext}` + if (existsSync(path)) { + return { format: ext, path } + } + } + + return { format: "none", path: `${basePath}.json` } +} + +export function parseConfigContent>( + content: string, + format: Exclude +): T { + if (format === "toml") { + return parseToml(content) + } + return parseJsonc(content) +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 07d8bd8605..495c111156 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -14,6 +14,8 @@ export * from "./data-path" export * from "./config-errors" export * from "./claude-config-dir" export * from "./jsonc-parser" +export * from "./toml-parser" +export * from "./config-detector" export * from "./migration" export * from "./opencode-config-dir" export type { diff --git a/src/shared/jsonc-parser.test.ts b/src/shared/jsonc-parser.test.ts index 1850a7e6ba..6f146844fa 100644 --- a/src/shared/jsonc-parser.test.ts +++ b/src/shared/jsonc-parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { detectConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser" +import { parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser" +import { detectConfigFile } from "./config-detector" import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" diff --git a/src/shared/jsonc-parser.ts b/src/shared/jsonc-parser.ts index c7b2fa749c..1f3bedad49 100644 --- a/src/shared/jsonc-parser.ts +++ b/src/shared/jsonc-parser.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs" +import { readFileSync } from "node:fs" import { parse, ParseError, printParseErrorCode } from "jsonc-parser" export interface JsoncParseResult { @@ -48,19 +48,3 @@ export function readJsoncFile(filePath: string): T | null { 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 } -} diff --git a/src/shared/migration.ts b/src/shared/migration.ts index 969d21392a..cee9f594a8 100644 --- a/src/shared/migration.ts +++ b/src/shared/migration.ts @@ -2,4 +2,4 @@ export { AGENT_NAME_MAP, BUILTIN_AGENT_NAMES, migrateAgentNames } from "./migrat export { HOOK_NAME_MAP, migrateHookNames } from "./migration/hook-names" export { MODEL_VERSION_MAP, migrateModelVersions } from "./migration/model-versions" export { MODEL_TO_CATEGORY_MAP, migrateAgentConfigToCategory, shouldDeleteAgentConfig } from "./migration/agent-category" -export { migrateConfigFile } from "./migration/config-migration" +export { migrateConfigFile, type ConfigFileFormat } from "./migration/config-migration" diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index b3051efb96..3f2c7abab1 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -4,9 +4,12 @@ import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names" import { migrateHookNames } from "./hook-names" import { migrateModelVersions } from "./model-versions" +export type ConfigFileFormat = "json" | "jsonc" | "toml" + export function migrateConfigFile( configPath: string, - rawConfig: Record + rawConfig: Record, + format: ConfigFileFormat = "json" ): boolean { const copy = structuredClone(rawConfig) let needsWrite = false @@ -108,11 +111,17 @@ export function migrateConfigFile( } let writeSucceeded = false - try { - fs.writeFileSync(configPath, JSON.stringify(copy, null, 2) + "\n", "utf-8") - writeSucceeded = true - } catch (err) { - log(`Failed to write migrated config to ${configPath}:`, err) + + if (format === "toml") { + log(`Skipping file write for TOML config (migrations applied in-memory): ${configPath}`) + writeSucceeded = false + } else { + try { + fs.writeFileSync(configPath, JSON.stringify(copy, null, 2) + "\n", "utf-8") + writeSucceeded = true + } catch (err) { + log(`Failed to write migrated config to ${configPath}:`, err) + } } for (const key of Object.keys(rawConfig)) { diff --git a/src/shared/toml-parser.test.ts b/src/shared/toml-parser.test.ts new file mode 100644 index 0000000000..b78ab6f4fd --- /dev/null +++ b/src/shared/toml-parser.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from "bun:test" +import { parseToml, parseTomlSafe, readTomlFile } from "./toml-parser" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +describe("parseToml", () => { + test("parses simple key-value pairs", () => { + // given + const toml = `key = "value"` + + // when + const result = parseToml<{ key: string }>(toml) + + // then + expect(result.key).toBe("value") + }) + + test("parses integer values", () => { + // given + const toml = `count = 42` + + // when + const result = parseToml<{ count: number }>(toml) + + // then + expect(result.count).toBe(42) + }) + + test("parses float values", () => { + // given + const toml = `temperature = 0.1` + + // when + const result = parseToml<{ temperature: number }>(toml) + + // then + expect(result.temperature).toBe(0.1) + }) + + test("parses boolean values", () => { + // given + const toml = ` +enabled = true +disabled = false` + + // when + const result = parseToml<{ enabled: boolean; disabled: boolean }>(toml) + + // then + expect(result.enabled).toBe(true) + expect(result.disabled).toBe(false) + }) + + test("parses arrays", () => { + // given + const toml = `items = ["a", "b", "c"]` + + // when + const result = parseToml<{ items: string[] }>(toml) + + // then + expect(result.items).toEqual(["a", "b", "c"]) + }) + + test("parses tables (sections)", () => { + // given + const toml = ` +[agents.sisyphus] +model = "anthropic/claude-opus-4-6" +temperature = 0.1` + + // when + const result = parseToml<{ + agents: { sisyphus: { model: string; temperature: number } } + }>(toml) + + // then + expect(result.agents.sisyphus.model).toBe("anthropic/claude-opus-4-6") + expect(result.agents.sisyphus.temperature).toBe(0.1) + }) + + test("parses inline tables", () => { + // given + const toml = `point = { x = 1, y = 2 }` + + // when + const result = parseToml<{ point: { x: number; y: number } }>(toml) + + // then + expect(result.point.x).toBe(1) + expect(result.point.y).toBe(2) + }) + + test("parses comments (ignored)", () => { + // given + const toml = ` +# This is a comment +key = "value" # inline comment` + + // when + const result = parseToml<{ key: string }>(toml) + + // then + expect(result.key).toBe("value") + }) + + test("parses multiline strings", () => { + // given + const toml = `prompt = """ +This is a +multiline string +"""` + + // when + const result = parseToml<{ prompt: string }>(toml) + + // then + expect(result.prompt).toContain("multiline string") + }) + + test("parses complex config structure", () => { + // given + const toml = ` +# Oh My OpenCode config in TOML +disabled_hooks = ["comment-checker"] + +[agents.sisyphus] +model = "anthropic/claude-opus-4-6" +temperature = 0.1 + +[agents.oracle] +model = "openai/gpt-5.2" + +[claude_code] +enabled = true +` + + // when + const result = parseToml<{ + agents: { sisyphus: { model: string; temperature: number }; oracle: { model: string } } + disabled_hooks: string[] + claude_code: { enabled: boolean } + }>(toml) + + // then + expect(result.agents.sisyphus.model).toBe("anthropic/claude-opus-4-6") + expect(result.agents.sisyphus.temperature).toBe(0.1) + expect(result.agents.oracle.model).toBe("openai/gpt-5.2") + expect(result.disabled_hooks).toEqual(["comment-checker"]) + expect(result.claude_code.enabled).toBe(true) + }) + + test("throws on invalid TOML", () => { + // given + const invalid = `key = invalid value` + + // when + // then + expect(() => parseToml(invalid)).toThrow() + }) + + test("throws on invalid table syntax", () => { + // given + const invalid = `[table` + + // when + // then + expect(() => parseToml(invalid)).toThrow() + }) +}) + +describe("parseTomlSafe", () => { + test("returns data on valid TOML", () => { + // given + const toml = `key = "value"` + + // when + const result = parseTomlSafe<{ key: string }>(toml) + + // then + expect(result.data).not.toBeNull() + expect(result.data?.key).toBe("value") + expect(result.errors).toHaveLength(0) + }) + + test("returns errors on invalid TOML", () => { + // given + const invalid = `key = invalid` + + // when + const result = parseTomlSafe(invalid) + + // then + expect(result.data).toBeNull() + expect(result.errors.length).toBeGreaterThan(0) + }) +}) + +describe("readTomlFile", () => { + const testDir = join(__dirname, ".test-toml") + const testFile = join(testDir, "config.toml") + + test("reads and parses valid TOML file", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + const content = ` +# Comment +[agents] +model = "test" +` + writeFileSync(testFile, content) + + // when + const result = readTomlFile<{ agents: { model: string } }>(testFile) + + // then + expect(result).not.toBeNull() + expect(result?.agents.model).toBe("test") + + rmSync(testDir, { recursive: true, force: true }) + }) + + test("returns null for non-existent file", () => { + // given + const nonExistent = join(testDir, "does-not-exist.toml") + + // when + const result = readTomlFile(nonExistent) + + // then + expect(result).toBeNull() + }) + + test("returns null for malformed TOML", () => { + // given + if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true }) + writeFileSync(testFile, "key = invalid") + + // when + const result = readTomlFile(testFile) + + // then + expect(result).toBeNull() + + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/toml-parser.ts b/src/shared/toml-parser.ts new file mode 100644 index 0000000000..717be3c775 --- /dev/null +++ b/src/shared/toml-parser.ts @@ -0,0 +1,44 @@ +import { existsSync, readFileSync } from "node:fs" +import { parse } from "smol-toml" + +export interface TomlParseResult { + data: T | null + errors: Array<{ message: string; line?: number; offset?: number }> +} + +/** + * Parse TOML content string into a JavaScript object. + * @throws SyntaxError if the TOML content is invalid + */ +export function parseToml(content: string): T { + return parse(content) as T +} + +/** + * Safely parse TOML content, returning errors instead of throwing. + */ +export function parseTomlSafe(content: string): TomlParseResult { + try { + const data = parse(content) as T + return { data, errors: [] } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { data: null, errors: [{ message }] } + } +} + +/** + * Read and parse a TOML file. + * @returns Parsed data or null if file doesn't exist or is invalid + */ +export function readTomlFile(filePath: string): T | null { + try { + if (!existsSync(filePath)) { + return null + } + const content = readFileSync(filePath, "utf-8") + return parseToml(content) + } catch { + return null + } +}