merge M2 into implement/2026-05-18-stef-openclaw-skills
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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";
|
||||
import { CLIENT_NAMES, isWindows } from "./constants.js";
|
||||
|
||||
export interface ResolvedConfig {
|
||||
paths: Partial<Record<ClientName, string>>;
|
||||
defaultClient?: ClientName;
|
||||
}
|
||||
|
||||
export interface ResolveConfigOptions {
|
||||
flags?: Record<string, string | boolean | undefined>;
|
||||
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 result = spawnSync(isWindows() ? "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<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
fileConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
} catch {
|
||||
fileConfig = {};
|
||||
}
|
||||
}
|
||||
|
||||
const filePaths = (fileConfig.paths ?? {}) as Partial<
|
||||
Record<ClientName, string>
|
||||
>;
|
||||
const fileDefault = fileConfig.defaultClient as ClientName | undefined;
|
||||
|
||||
const paths: Partial<Record<ClientName, string>> = {};
|
||||
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 &&
|
||||
CLIENT_NAMES.includes(defaultClient as ClientName)
|
||||
) {
|
||||
result.defaultClient = defaultClient as ClientName;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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";
|
||||
import { CLIENT_NAMES, isWindows } from "./constants.js";
|
||||
|
||||
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].replace(/^v/, "") : undefined;
|
||||
}
|
||||
|
||||
function findBinary(
|
||||
name: string,
|
||||
pathEnv: string,
|
||||
spawnSyncImpl: typeof defaultSpawnSync,
|
||||
existsSyncImpl: typeof defaultExistsSync
|
||||
): string | undefined {
|
||||
// Try system `which` / `where` first
|
||||
const whichResult = spawnSyncImpl(isWindows() ? "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 = isWindows() ? ";" : ":";
|
||||
const ext = isWindows() ? ".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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { status: number; stdout: string }>
|
||||
): 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<string>
|
||||
): 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<string, { status: number; stdout: string }>();
|
||||
|
||||
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<string, { status: number; stdout: string }>();
|
||||
// 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, "2.0.0");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user