feat(M2): Client Detection & Configuration

This commit is contained in:
2026-05-18 17:53:47 -05:00
parent 185083ace8
commit 82fcd3363c
5 changed files with 46 additions and 14 deletions
+6 -5
View File
@@ -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<Record<ClientName, string>>;
@@ -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;
+7
View File
@@ -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";
}
+5 -8
View File
@@ -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;
@@ -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);
});
});
+1 -1
View File
@@ -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");
});
});