From 2642c280a299b629364a6863808b0d849374a75d Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:51:07 -0500 Subject: [PATCH 1/3] feat(S-202): Test-drive and implement src/detect.ts --- tools/ai-cli-dispatch/src/detect.ts | 73 +++++++++++ tools/ai-cli-dispatch/tests/detect.test.ts | 144 +++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tools/ai-cli-dispatch/src/detect.ts create mode 100644 tools/ai-cli-dispatch/tests/detect.test.ts diff --git a/tools/ai-cli-dispatch/src/detect.ts b/tools/ai-cli-dispatch/src/detect.ts new file mode 100644 index 0000000..2c9b6c6 --- /dev/null +++ b/tools/ai-cli-dispatch/src/detect.ts @@ -0,0 +1,73 @@ +import { spawnSync as defaultSpawnSync } from "node:child_process"; +import { existsSync as defaultExistsSync } from "node:fs"; +import { join } from "node:path"; +import { type ClientName, type ClientInfo } from "./types.js"; + +const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; + +export interface DetectOptions { + pathEnv?: string; + spawnSync?: typeof defaultSpawnSync; + existsSync?: typeof defaultExistsSync; +} + +function parseVersion(stdout: string): string | undefined { + const match = stdout.match(/v?\d+\.\d+\.\d+(?:[-+.]\w+)*/); + return match ? match[0] : undefined; +} + +function findBinary( + name: string, + pathEnv: string, + spawnSyncImpl: typeof defaultSpawnSync, + existsSyncImpl: typeof defaultExistsSync +): string | undefined { + const isWin = process.platform === "win32"; + + // Try system `which` / `where` first + const whichResult = spawnSyncImpl(isWin ? "where" : "which", [name], { + encoding: "utf-8", + env: { ...process.env, PATH: pathEnv }, + }); + if (whichResult.status === 0) { + const line = whichResult.stdout.trim().split("\n")[0]; + if (line) return line; + } + + // Fallback: walk PATH directories manually + const sep = isWin ? ";" : ":"; + const ext = isWin ? ".exe" : ""; + const dirs = pathEnv.split(sep); + for (const dir of dirs) { + if (!dir) continue; + const fullPath = join(dir, name + ext); + if (existsSyncImpl(fullPath)) { + return fullPath; + } + } + + return undefined; +} + +export function detectClients(options?: DetectOptions): ClientInfo[] { + const spawnSyncImpl = options?.spawnSync ?? defaultSpawnSync; + const existsSyncImpl = options?.existsSync ?? defaultExistsSync; + const pathEnv = options?.pathEnv ?? process.env.PATH ?? ""; + + return CLIENT_NAMES.map((name) => { + const path = findBinary(name, pathEnv, spawnSyncImpl, existsSyncImpl); + if (!path) { + return { name, found: false }; + } + + const versionResult = spawnSyncImpl(path, ["--version"], { + encoding: "utf-8", + }); + const version = + versionResult.status === 0 + ? parseVersion(versionResult.stdout) + : undefined; + + return { name, path, version, found: true }; + }); +} diff --git a/tools/ai-cli-dispatch/tests/detect.test.ts b/tools/ai-cli-dispatch/tests/detect.test.ts new file mode 100644 index 0000000..fd09970 --- /dev/null +++ b/tools/ai-cli-dispatch/tests/detect.test.ts @@ -0,0 +1,144 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { detectClients } from "../src/detect.js"; + +function mockSpawnSync( + responses: Map +): any { + return (cmd: string, args: string[], _opts: unknown) => { + const key = [cmd, ...args].join(" "); + const hit = responses.get(key); + if (hit) { + return { status: hit.status, stdout: hit.stdout, stderr: "" }; + } + return { status: 1, stdout: "", stderr: "" }; + }; +} + +function mockExistsSync( + allowedPaths: Set +): any { + return (p: string) => allowedPaths.has(p); +} + +describe("detectClients", () => { + it("returns all three clients when found on PATH", () => { + const responses = new Map([ + ["which codex", { status: 0, stdout: "/usr/local/bin/codex\n" }], + ["which claude", { status: 0, stdout: "/usr/local/bin/claude\n" }], + ["which opencode", { status: 0, stdout: "/usr/local/bin/opencode\n" }], + ["/usr/local/bin/codex --version", { status: 0, stdout: "1.0.0\n" }], + ["/usr/local/bin/claude --version", { status: 0, stdout: "1.0.0\n" }], + ["/usr/local/bin/opencode --version", { status: 0, stdout: "1.0.0\n" }], + ]); + + const clients = detectClients({ + pathEnv: "/usr/local/bin", + spawnSync: mockSpawnSync(responses), + existsSync: () => false, + }); + + assert.strictEqual(clients.length, 3); + for (const c of clients) { + assert.strictEqual(c.found, true); + assert.ok(c.path?.includes(c.name)); + assert.strictEqual(c.version, "1.0.0"); + } + }); + + it("returns found: false for a missing client", () => { + const responses = new Map([ + ["which codex", { status: 0, stdout: "/usr/local/bin/codex\n" }], + ["which claude", { status: 0, stdout: "/usr/local/bin/claude\n" }], + ["which opencode", { status: 1, stdout: "" }], + ["/usr/local/bin/codex --version", { status: 0, stdout: "1.0.0\n" }], + ["/usr/local/bin/claude --version", { status: 0, stdout: "1.0.0\n" }], + ]); + + const clients = detectClients({ + pathEnv: "/usr/local/bin", + spawnSync: mockSpawnSync(responses), + existsSync: () => false, + }); + + assert.strictEqual(clients.length, 3); + const opencode = clients.find((c) => c.name === "opencode"); + assert.ok(opencode); + assert.strictEqual(opencode!.found, false); + assert.strictEqual(opencode!.path, undefined); + assert.strictEqual(opencode!.version, undefined); + + const codex = clients.find((c) => c.name === "codex"); + assert.ok(codex); + assert.strictEqual(codex!.found, true); + }); + + it("returns found: false for all when none are on PATH", () => { + const responses = new Map(); + + const clients = detectClients({ + pathEnv: "/usr/local/bin", + spawnSync: mockSpawnSync(responses), + existsSync: () => false, + }); + + assert.strictEqual(clients.length, 3); + for (const c of clients) { + assert.strictEqual(c.found, false); + assert.strictEqual(c.path, undefined); + assert.strictEqual(c.version, undefined); + } + }); + + it("handles PATH with duplicate entries via fallback walk", () => { + const responses = new Map(); + // which fails for all, so fallback walk is used + const allowedPaths = new Set([ + "/usr/bin/codex", + "/usr/bin/claude", + "/usr/bin/opencode", + ]); + + const clients = detectClients({ + pathEnv: "/usr/bin:/usr/bin:/usr/local/bin", + spawnSync: mockSpawnSync(responses), + existsSync: mockExistsSync(allowedPaths), + }); + + assert.strictEqual(clients.length, 3); + for (const c of clients) { + assert.strictEqual(c.found, true); + assert.strictEqual(c.path, `/usr/bin/${c.name}`); + } + }); + + it("parses version string from noisy output", () => { + const responses = new Map([ + ["which codex", { status: 0, stdout: "/opt/bin/codex\n" }], + ["which claude", { status: 0, stdout: "/opt/bin/claude\n" }], + ["which opencode", { status: 0, stdout: "/opt/bin/opencode\n" }], + [ + "/opt/bin/codex --version", + { status: 0, stdout: "codex version 1.2.3 (build abc)\n" }, + ], + [ + "/opt/bin/claude --version", + { status: 0, stdout: "Claude Code 0.4.5-beta\n" }, + ], + ["/opt/bin/opencode --version", { status: 0, stdout: "v2.0.0\n" }], + ]); + + const clients = detectClients({ + pathEnv: "/opt/bin", + spawnSync: mockSpawnSync(responses), + existsSync: () => false, + }); + + assert.strictEqual(clients.find((c) => c.name === "codex")!.version, "1.2.3"); + assert.strictEqual( + clients.find((c) => c.name === "claude")!.version, + "0.4.5-beta" + ); + assert.strictEqual(clients.find((c) => c.name === "opencode")!.version, "v2.0.0"); + }); +}); From f3458734d403c6338897e399d087e08103fa7112 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:51:44 -0500 Subject: [PATCH 2/3] feat(S-203): Test-drive and implement src/config.ts --- tools/ai-cli-dispatch/src/config.ts | 92 +++++++++++++++++ tools/ai-cli-dispatch/tests/config.test.ts | 111 +++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 tools/ai-cli-dispatch/src/config.ts create mode 100644 tools/ai-cli-dispatch/tests/config.test.ts 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"); + }); +}); From 82fcd3363cc3c88edf82da3e568d7e893f16fe04 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:53:47 -0500 Subject: [PATCH 3/3] feat(M2): Client Detection & Configuration --- tools/ai-cli-dispatch/src/config.ts | 11 +++++---- tools/ai-cli-dispatch/src/constants.ts | 7 ++++++ tools/ai-cli-dispatch/src/detect.ts | 13 ++++------- tools/ai-cli-dispatch/tests/config.test.ts | 27 ++++++++++++++++++++++ tools/ai-cli-dispatch/tests/detect.test.ts | 2 +- 5 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 tools/ai-cli-dispatch/src/constants.ts diff --git a/tools/ai-cli-dispatch/src/config.ts b/tools/ai-cli-dispatch/src/config.ts index 73adf86..6fd1c15 100644 --- a/tools/ai-cli-dispatch/src/config.ts +++ b/tools/ai-cli-dispatch/src/config.ts @@ -5,8 +5,7 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import type { ClientName } from "./types.js"; - -const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; +import { CLIENT_NAMES, isWindows } from "./constants.js"; export interface ResolvedConfig { paths: Partial>; @@ -23,8 +22,7 @@ export interface ResolveConfigOptions { } function defaultWhichSync(cmd: string): string | undefined { - const isWin = process.platform === "win32"; - const result = spawnSync(isWin ? "where" : "which", [cmd], { + const result = spawnSync(isWindows() ? "where" : "which", [cmd], { encoding: "utf-8", }); if (result.status === 0) { @@ -85,7 +83,10 @@ export function resolveConfig( fileDefault; const result: ResolvedConfig = { paths }; - if (defaultClient !== undefined) { + if ( + defaultClient !== undefined && + CLIENT_NAMES.includes(defaultClient as ClientName) + ) { result.defaultClient = defaultClient as ClientName; } return result; diff --git a/tools/ai-cli-dispatch/src/constants.ts b/tools/ai-cli-dispatch/src/constants.ts new file mode 100644 index 0000000..d2930a4 --- /dev/null +++ b/tools/ai-cli-dispatch/src/constants.ts @@ -0,0 +1,7 @@ +import type { ClientName } from "./types.js"; + +export const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; + +export function isWindows(): boolean { + return process.platform === "win32"; +} diff --git a/tools/ai-cli-dispatch/src/detect.ts b/tools/ai-cli-dispatch/src/detect.ts index 2c9b6c6..837d915 100644 --- a/tools/ai-cli-dispatch/src/detect.ts +++ b/tools/ai-cli-dispatch/src/detect.ts @@ -2,8 +2,7 @@ import { spawnSync as defaultSpawnSync } from "node:child_process"; import { existsSync as defaultExistsSync } from "node:fs"; import { join } from "node:path"; import { type ClientName, type ClientInfo } from "./types.js"; - -const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; +import { CLIENT_NAMES, isWindows } from "./constants.js"; export interface DetectOptions { pathEnv?: string; @@ -13,7 +12,7 @@ export interface DetectOptions { function parseVersion(stdout: string): string | undefined { const match = stdout.match(/v?\d+\.\d+\.\d+(?:[-+.]\w+)*/); - return match ? match[0] : undefined; + return match ? match[0].replace(/^v/, "") : undefined; } function findBinary( @@ -22,10 +21,8 @@ function findBinary( spawnSyncImpl: typeof defaultSpawnSync, existsSyncImpl: typeof defaultExistsSync ): string | undefined { - const isWin = process.platform === "win32"; - // Try system `which` / `where` first - const whichResult = spawnSyncImpl(isWin ? "where" : "which", [name], { + const whichResult = spawnSyncImpl(isWindows() ? "where" : "which", [name], { encoding: "utf-8", env: { ...process.env, PATH: pathEnv }, }); @@ -35,8 +32,8 @@ function findBinary( } // Fallback: walk PATH directories manually - const sep = isWin ? ";" : ":"; - const ext = isWin ? ".exe" : ""; + const sep = isWindows() ? ";" : ":"; + const ext = isWindows() ? ".exe" : ""; const dirs = pathEnv.split(sep); for (const dir of dirs) { if (!dir) continue; diff --git a/tools/ai-cli-dispatch/tests/config.test.ts b/tools/ai-cli-dispatch/tests/config.test.ts index 89b74b9..99d505c 100644 --- a/tools/ai-cli-dispatch/tests/config.test.ts +++ b/tools/ai-cli-dispatch/tests/config.test.ts @@ -108,4 +108,31 @@ describe("resolveConfig", () => { }); assert.strictEqual(config.defaultClient, "codex"); }); + + it("ignores invalid defaultClient from file", () => { + const config = resolveConfig({ + existsSync: () => true, + readFileSync: () => JSON.stringify({ defaultClient: "foo" }), + whichSync: () => undefined, + }); + assert.strictEqual(config.defaultClient, undefined); + }); + + it("ignores invalid defaultClient from env var", () => { + const config = resolveConfig({ + env: { AI_CLI_DEFAULT_CLIENT: "bar" }, + existsSync: () => false, + whichSync: () => undefined, + }); + assert.strictEqual(config.defaultClient, undefined); + }); + + it("ignores invalid defaultClient from flag", () => { + const config = resolveConfig({ + flags: { "default-client": "baz" }, + existsSync: () => false, + whichSync: () => undefined, + }); + assert.strictEqual(config.defaultClient, undefined); + }); }); diff --git a/tools/ai-cli-dispatch/tests/detect.test.ts b/tools/ai-cli-dispatch/tests/detect.test.ts index fd09970..a89e6bd 100644 --- a/tools/ai-cli-dispatch/tests/detect.test.ts +++ b/tools/ai-cli-dispatch/tests/detect.test.ts @@ -139,6 +139,6 @@ describe("detectClients", () => { clients.find((c) => c.name === "claude")!.version, "0.4.5-beta" ); - assert.strictEqual(clients.find((c) => c.name === "opencode")!.version, "v2.0.0"); + assert.strictEqual(clients.find((c) => c.name === "opencode")!.version, "2.0.0"); }); });