From 82fcd3363cc3c88edf82da3e568d7e893f16fe04 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:53:47 -0500 Subject: [PATCH] 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"); }); });