merge S-203 into M2
This commit is contained in:
@@ -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<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 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<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) {
|
||||||
|
result.defaultClient = defaultClient as ClientName;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user