From 2642c280a299b629364a6863808b0d849374a75d Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Mon, 18 May 2026 17:51:07 -0500 Subject: [PATCH] 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"); + }); +});