diff --git a/tools/ai-cli-dispatch/src/config.ts b/tools/ai-cli-dispatch/src/config.ts new file mode 100644 index 0000000..73adf86 --- /dev/null +++ b/tools/ai-cli-dispatch/src/config.ts @@ -0,0 +1,92 @@ +import { homedir } from "node:os"; +import { + readFileSync as realReadFileSync, + existsSync as realExistsSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import type { ClientName } from "./types.js"; + +const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; + +export interface ResolvedConfig { + paths: Partial>; + defaultClient?: ClientName; +} + +export interface ResolveConfigOptions { + flags?: Record; + env?: NodeJS.ProcessEnv; + homeDir?: string; + readFileSync?: (path: string, encoding: BufferEncoding) => string; + existsSync?: (path: string) => boolean; + whichSync?: (cmd: string) => string | undefined; +} + +function defaultWhichSync(cmd: string): string | undefined { + const isWin = process.platform === "win32"; + const result = spawnSync(isWin ? "where" : "which", [cmd], { + encoding: "utf-8", + }); + if (result.status === 0) { + return result.stdout.trim().split("\n")[0]; + } + return undefined; +} + +export function resolveConfig( + options: ResolveConfigOptions = {} +): ResolvedConfig { + const { + flags = {}, + env = process.env, + homeDir = homedir(), + readFileSync = realReadFileSync, + existsSync = realExistsSync, + whichSync = defaultWhichSync, + } = options; + + const configPath = `${homeDir}/.openclaw/ai-cli-dispatch.json`; + + let fileConfig: Record = {}; + if (existsSync(configPath)) { + try { + fileConfig = JSON.parse(readFileSync(configPath, "utf-8")); + } catch { + fileConfig = {}; + } + } + + const filePaths = (fileConfig.paths ?? {}) as Partial< + Record + >; + const fileDefault = fileConfig.defaultClient as ClientName | undefined; + + const paths: Partial> = {}; + for (const name of CLIENT_NAMES) { + const flagKey = `${name}-path`; + const envKey = `AI_CLI_${name.toUpperCase()}_PATH`; + const resolved = + (typeof flags[flagKey] === "string" + ? (flags[flagKey] as string) + : undefined) ?? + env[envKey] ?? + filePaths[name] ?? + whichSync(name); + if (resolved !== undefined) { + paths[name] = resolved; + } + } + + const defaultClient = + (typeof flags["default-client"] === "string" + ? (flags["default-client"] as string) + : undefined) ?? + env.AI_CLI_DEFAULT_CLIENT ?? + fileDefault; + + const result: ResolvedConfig = { paths }; + if (defaultClient !== undefined) { + result.defaultClient = defaultClient as ClientName; + } + return result; +} diff --git a/tools/ai-cli-dispatch/tests/config.test.ts b/tools/ai-cli-dispatch/tests/config.test.ts new file mode 100644 index 0000000..89b74b9 --- /dev/null +++ b/tools/ai-cli-dispatch/tests/config.test.ts @@ -0,0 +1,111 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { resolveConfig } from "../src/config.js"; + +describe("resolveConfig", () => { + it("returns empty config when no sources are present", () => { + const config = resolveConfig({ + existsSync: () => false, + whichSync: () => undefined, + }); + assert.deepStrictEqual(config.paths, {}); + assert.strictEqual(config.defaultClient, undefined); + }); + + it("loads config from file", () => { + const config = resolveConfig({ + existsSync: () => true, + readFileSync: () => + JSON.stringify({ + paths: { codex: "/file/codex", claude: "/file/claude" }, + defaultClient: "claude", + }), + whichSync: () => undefined, + }); + assert.strictEqual(config.paths.codex, "/file/codex"); + assert.strictEqual(config.paths.claude, "/file/claude"); + assert.strictEqual(config.defaultClient, "claude"); + }); + + it("overrides file config with env vars", () => { + const config = resolveConfig({ + env: { AI_CLI_CODEX_PATH: "/env/codex" }, + existsSync: () => true, + readFileSync: () => JSON.stringify({ paths: { codex: "/file/codex" } }), + whichSync: () => undefined, + }); + assert.strictEqual(config.paths.codex, "/env/codex"); + }); + + it("overrides env vars with CLI flags", () => { + const config = resolveConfig({ + flags: { "codex-path": "/flag/codex" }, + env: { AI_CLI_CODEX_PATH: "/env/codex" }, + existsSync: () => true, + readFileSync: () => JSON.stringify({ paths: { codex: "/file/codex" } }), + whichSync: () => undefined, + }); + assert.strictEqual(config.paths.codex, "/flag/codex"); + }); + + it("falls back to PATH detection when config file is missing", () => { + const config = resolveConfig({ + existsSync: () => false, + whichSync: (cmd) => + cmd === "opencode" ? "/usr/bin/opencode" : undefined, + }); + assert.strictEqual(config.paths.opencode, "/usr/bin/opencode"); + assert.strictEqual(config.paths.codex, undefined); + assert.strictEqual(config.paths.claude, undefined); + }); + + it("respects full priority ordering: flag > env > file > PATH", () => { + const config = resolveConfig({ + flags: { + "codex-path": "/flag/codex", + "claude-path": "/flag/claude", + "opencode-path": "/flag/opencode", + "default-client": "opencode", + }, + env: { + AI_CLI_CODEX_PATH: "/env/codex", + AI_CLI_CLAUDE_PATH: "/env/claude", + AI_CLI_OPENCODE_PATH: "/env/opencode", + AI_CLI_DEFAULT_CLIENT: "claude", + }, + existsSync: () => true, + readFileSync: () => + JSON.stringify({ + paths: { + codex: "/file/codex", + claude: "/file/claude", + opencode: "/file/opencode", + }, + defaultClient: "codex", + }), + whichSync: (cmd) => `/path/${cmd}`, + }); + assert.strictEqual(config.paths.codex, "/flag/codex"); + assert.strictEqual(config.paths.claude, "/flag/claude"); + assert.strictEqual(config.paths.opencode, "/flag/opencode"); + assert.strictEqual(config.defaultClient, "opencode"); + }); + + it("uses env var for default client when no flag is given", () => { + const config = resolveConfig({ + env: { AI_CLI_DEFAULT_CLIENT: "claude" }, + existsSync: () => false, + whichSync: () => undefined, + }); + assert.strictEqual(config.defaultClient, "claude"); + }); + + it("uses file default client when no env var is given", () => { + const config = resolveConfig({ + existsSync: () => true, + readFileSync: () => JSON.stringify({ defaultClient: "codex" }), + whichSync: () => undefined, + }); + assert.strictEqual(config.defaultClient, "codex"); + }); +});