mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix(config agent): align agent configs to Alibaba Cloud Model Studio docs
Adopt the code-review findings and reconcile every writer with the official
Model Studio docs (with cc-switch / each agent's own docs as secondary refs).
- qwen-code: drop deprecated security.auth.apiKey/baseUrl (keep only
selectedType), restore $version:3, model:{name}; env BAILIAN_API_KEY.
- codex: official env_key="OPENAI_API_KEY" structure (drop requires_openai_auth
/ model_reasoning_effort / disable_response_storage); respect $CODEX_HOME.
- hermes: revert to official flat model.* block; api_mode only for anthropic
endpoints (omitted for OpenAI-compatible).
- opencode: drop setCacheKey; target existing opencode.jsonc when present.
- openclaw: stop hardcoding 1M contextWindow; add optional --context-window;
add agents.defaults.models.
- claude-code: respect $CLAUDE_CONFIG_DIR.
- utils: on parse failure, throw and keep the original file (no silent wipe).
- tests: rewrite writer unit tests + add missing-flag e2e for every required
flag; regenerate skill reference; document `bl config agent` in READMEs.
This commit is contained in:
@@ -194,6 +194,12 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# Configure a coding agent (Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes, Codex)
|
||||
# to use DashScope with one command
|
||||
bl config agent --agent claude-code \
|
||||
--base-url https://dashscope.aliyuncs.com/apps/anthropic \
|
||||
--api-key sk-xxxxx --model qwen3-max
|
||||
|
||||
# Self-update to latest version
|
||||
bl update
|
||||
```
|
||||
|
||||
@@ -192,6 +192,11 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# 一键配置编程 Agent(Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes、Codex)接入百炼
|
||||
bl config agent --agent claude-code \
|
||||
--base-url https://dashscope.aliyuncs.com/apps/anthropic \
|
||||
--api-key sk-xxxxx --model qwen3-max
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
```
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
|
||||
@@ -11,14 +16,30 @@ const FLAGS = {
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "API base URL",
|
||||
required: true,
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "API key",
|
||||
required: true,
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
contextWindow: {
|
||||
type: "number",
|
||||
valueHint: "<tokens>",
|
||||
description:
|
||||
"Context window in tokens (openclaw only; omit to use the agent default)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
@@ -53,13 +74,21 @@ export default defineCommand({
|
||||
base_url: baseUrl,
|
||||
api_key: maskToken(apiKey),
|
||||
model,
|
||||
...(flags.contextWindow !== undefined
|
||||
? { context_window: flags.contextWindow }
|
||||
: {}),
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const params: WriteParams = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
contextWindow: flags.contextWindow,
|
||||
};
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
claudeConfigDir,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
const settingsPath = join(claudeConfigDir(), "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
|
||||
@@ -1,32 +1,53 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
writeTextAtomic,
|
||||
codexHome,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
/**
|
||||
* Codex config follows the Alibaba Cloud Model Studio doc: `config.toml`
|
||||
* declares the provider with `env_key = "OPENAI_API_KEY"` and
|
||||
* `wire_api = "responses"`; the API key is stored in `auth.json`
|
||||
* (Codex's native API-key store, equivalent to exporting OPENAI_API_KEY).
|
||||
* Config root respects `$CODEX_HOME`.
|
||||
*/
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
const configPath = join(codexHome(), "config.toml");
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
// config.toml — merge so unrelated settings (mcp_servers, approval_policy,
|
||||
// other providers, ...) are preserved.
|
||||
backup(configPath);
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
config = parseToml(readFileSync(configPath, "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch (error) {
|
||||
throw new BailianError(
|
||||
`Failed to parse existing config: ${configPath}`,
|
||||
ExitCode.GENERAL,
|
||||
`Fix or back up the file, then retry. Underlying error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
@@ -34,15 +55,15 @@ export default {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: "responses",
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
const authPath = join(codexHome(), "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
auth.OPENAI_API_KEY = apiKey;
|
||||
|
||||
@@ -2,10 +2,19 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import {
|
||||
backup,
|
||||
writeTextAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/**
|
||||
* Hermes config follows the Alibaba Cloud Model Studio doc: a flat `model`
|
||||
* block carries the endpoint inline. Anthropic-compatible endpoints set
|
||||
* `api_mode: anthropic_messages`; OpenAI-compatible endpoints omit `api_mode`.
|
||||
*/
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
@@ -16,32 +25,30 @@ export default {
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
|
||||
{}) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new BailianError(
|
||||
`Failed to parse existing config: ${configPath}`,
|
||||
ExitCode.GENERAL,
|
||||
`Fix or back up the file, then retry. Underlying error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
const modelBlock: Record<string, unknown> = {
|
||||
default: model,
|
||||
provider: "custom",
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
// Anthropic endpoints require api_mode; OpenAI-compatible ones omit it.
|
||||
if (isAnthropicEndpoint(baseUrl))
|
||||
modelBlock.api_mode = "anthropic_messages";
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
config.model = modelBlock;
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/**
|
||||
* OpenClaw config follows the Alibaba Cloud Model Studio doc: a merged
|
||||
* `models.providers` entry plus an `agents.defaults` selection. `contextWindow`
|
||||
* is only written when the caller supplies it (`--context-window`); we never
|
||||
* fake a value, since an over-large window makes OpenClaw defer compaction and
|
||||
* hit server-side limits on smaller-context models.
|
||||
*/
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, contextWindow }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
|
||||
backup(configPath);
|
||||
@@ -14,27 +27,32 @@ export default {
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
},
|
||||
],
|
||||
const api = isAnthropicEndpoint(baseUrl)
|
||||
? "anthropic-messages"
|
||||
: "openai-completions";
|
||||
const modelEntry: Record<string, unknown> = {
|
||||
id: model,
|
||||
name: model,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
if (typeof contextWindow === "number")
|
||||
modelEntry.contextWindow = contextWindow;
|
||||
providers["bailian-cli"] = { baseUrl, apiKey, api, models: [modelEntry] };
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
// agents.defaults — primary selection + models map (per Model Studio doc).
|
||||
const modelRef = `bailian-cli/${model}`;
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
const defaultModel = (defaults.model ?? {}) as Record<string, unknown>;
|
||||
defaultModel.primary = modelRef;
|
||||
defaults.model = defaultModel;
|
||||
const defaultModels = (defaults.models ?? {}) as Record<string, unknown>;
|
||||
defaultModels[modelRef] = defaultModels[modelRef] ?? {};
|
||||
defaults.models = defaultModels;
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import { existsSync } from "fs";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/**
|
||||
* OpenCode config follows the Alibaba Cloud Model Studio doc: a `provider`
|
||||
* entry with the AI SDK `npm` package chosen by endpoint protocol. OpenCode
|
||||
* accepts either `opencode.json` or `opencode.jsonc`; we target an existing
|
||||
* `.jsonc` when present, else `.json`.
|
||||
*/
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
const dir = join(homedir(), ".config", "opencode");
|
||||
const jsoncPath = join(dir, "opencode.jsonc");
|
||||
const configPath = existsSync(jsoncPath)
|
||||
? jsoncPath
|
||||
: join(dir, "opencode.json");
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
@@ -13,11 +30,13 @@ export default {
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
const provider = (config.provider ?? {}) as Record<string, unknown>;
|
||||
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
|
||||
const npm = isAnthropicEndpoint(baseUrl)
|
||||
? "@ai-sdk/anthropic"
|
||||
: "@ai-sdk/openai-compatible";
|
||||
provider["bailian-cli"] = {
|
||||
npm,
|
||||
name: "Alibaba Cloud Model Studio",
|
||||
options: { baseURL: baseUrl, apiKey, setCacheKey: true },
|
||||
options: { baseURL: baseUrl, apiKey },
|
||||
models: { [model]: { name: model } },
|
||||
};
|
||||
config.provider = provider;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
const ENV_KEY = "BAILIAN_API_KEY";
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the model entry `name` and the env var name.
|
||||
* resolver indexes credentials/defaults by protocol. Structure follows the
|
||||
* Alibaba Cloud Model Studio doc: the API key lives in `env` (read via the
|
||||
* provider entry's `envKey`); `security.auth` only records the selected type.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
@@ -24,32 +31,40 @@ export default {
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
|
||||
// modelProviders[<protocol>] — upsert the model entry.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const entries = (providers[protocol] ?? []) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const displayName = `[Bailian] ${model}`;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = "bailian-cli";
|
||||
existing.name = displayName;
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
entries.push({ id: model, name: displayName, baseUrl, envKey: ENV_KEY });
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
// security.auth — only the selected protocol (no deprecated apiKey/baseUrl).
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
const auth = (security.auth ?? {}) as Record<string, unknown>;
|
||||
auth.selectedType = protocol;
|
||||
security.auth = auth;
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
settings.model = { name: model, baseUrl };
|
||||
// model + settings version (per Model Studio doc).
|
||||
const modelConfig = (settings.model ?? {}) as Record<string, unknown>;
|
||||
modelConfig.name = model;
|
||||
settings.model = modelConfig;
|
||||
settings.$version = 3;
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { homedir } from "os";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
copyFileSync,
|
||||
} from "fs";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** Optional context window (tokens); only some agents record it. */
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
@@ -20,13 +31,37 @@ export interface AgentDef {
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
/** Codex config root: `$CODEX_HOME` when set, else `~/.codex`. */
|
||||
export function codexHome(): string {
|
||||
const override = process.env.CODEX_HOME?.trim();
|
||||
return override && override.length > 0 ? override : join(homedir(), ".codex");
|
||||
}
|
||||
|
||||
/** Claude Code config dir: `$CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */
|
||||
export function claudeConfigDir(): string {
|
||||
const override = process.env.CLAUDE_CONFIG_DIR?.trim();
|
||||
return override && override.length > 0
|
||||
? override
|
||||
: join(homedir(), ".claude");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON object file. Missing file → `{}` (a fresh config). An existing
|
||||
* file that fails to parse throws instead of silently returning `{}` — that
|
||||
* would drop the user's content when we write the merged result back.
|
||||
*/
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
} catch (error) {
|
||||
throw new BailianError(
|
||||
`Failed to parse existing config: ${path}`,
|
||||
ExitCode.GENERAL,
|
||||
`Fix or back up the file, then retry. Underlying error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
} from "fs";
|
||||
import { tmpdir, homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
|
||||
@@ -12,7 +19,7 @@ import yaml from "yaml";
|
||||
|
||||
/**
|
||||
* Agent writer 单元测试:直接调用 writer,用临时 HOME 隔离文件系统。
|
||||
* writer 是纯文件 I/O,在进程内测试比 e2e 子进程更快、覆盖更全。
|
||||
* 结构以阿里云百炼官方文档为准。
|
||||
*/
|
||||
|
||||
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
@@ -20,18 +27,27 @@ const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
|
||||
|
||||
let home = "";
|
||||
let prevHome: string | undefined;
|
||||
let prevCodexHome: string | undefined;
|
||||
let prevClaudeDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
|
||||
prevHome = process.env.HOME;
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
prevClaudeDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.HOME = home;
|
||||
// homedir() 在 POSIX 读 $HOME;断言隔离生效。
|
||||
delete process.env.CODEX_HOME;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
expect(homedir()).toBe(home);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
if (prevClaudeDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = prevClaudeDir;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -41,7 +57,6 @@ function readJsonAt(...segments: string[]): Record<string, unknown> {
|
||||
|
||||
describe("config agent writers", () => {
|
||||
test("claude-code 写入 env 与 onboarding,并合并已有 env", () => {
|
||||
// 预置一个无关 env 键,验证合并保留
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
@@ -70,51 +85,96 @@ describe("config agent writers", () => {
|
||||
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
|
||||
test("claude-code 尊重 CLAUDE_CONFIG_DIR", () => {
|
||||
const dir = join(home, "custom-claude");
|
||||
process.env.CLAUDE_CONFIG_DIR = dir;
|
||||
claudeCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(dir, "settings.json"), "utf8"),
|
||||
);
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_BASE_URL).toBe(
|
||||
ANTHROPIC_URL,
|
||||
);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议(官方结构)", () => {
|
||||
qwenCode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-q",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const security = settings.security as { auth: Record<string, string> };
|
||||
const security = settings.security as { auth: Record<string, unknown> };
|
||||
expect(security.auth.selectedType).toBe("openai");
|
||||
expect(security.auth.apiKey).toBe("sk-q");
|
||||
expect(security.auth.baseUrl).toBe(OAI_URL);
|
||||
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
|
||||
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
|
||||
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
|
||||
// 不写已废弃的 apiKey/baseUrl
|
||||
expect(security.auth.apiKey).toBeUndefined();
|
||||
expect(security.auth.baseUrl).toBeUndefined();
|
||||
expect(settings.$version).toBe(3);
|
||||
expect((settings.env as Record<string, string>).BAILIAN_API_KEY).toBe(
|
||||
"sk-q",
|
||||
);
|
||||
expect(settings.model).toEqual({ name: "qwen3-coder-plus" });
|
||||
const providers = settings.modelProviders as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
expect(providers.openai[0]).toMatchObject({
|
||||
id: "qwen3-coder-plus",
|
||||
name: "bailian-cli",
|
||||
name: "[Bailian] qwen3-coder-plus",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "BAILIAN_CLI_API_KEY",
|
||||
envKey: "BAILIAN_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
test("qwen-code anthropic 端点走 anthropic 协议", () => {
|
||||
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
|
||||
qwenCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-q",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
|
||||
"anthropic",
|
||||
);
|
||||
expect(
|
||||
(settings.security as { auth: { selectedType: string } }).auth
|
||||
.selectedType,
|
||||
).toBe("anthropic");
|
||||
const providers = settings.modelProviders as Record<string, unknown>;
|
||||
expect(Array.isArray(providers.anthropic)).toBe(true);
|
||||
expect(providers.openai).toBeUndefined();
|
||||
});
|
||||
|
||||
test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
|
||||
qwenCode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-1",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
qwenCode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-2",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
|
||||
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>)
|
||||
.openai;
|
||||
expect(openaiEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => {
|
||||
test("opencode 按端点选 npm(无 setCacheKey),合并保留其它 provider", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".config", "opencode", "opencode.json"),
|
||||
JSON.stringify({ provider: { other: { name: "Other" } } }),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
opencode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-o",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const config = readJsonAt(".config", "opencode", "opencode.json");
|
||||
const provider = config.provider as Record<string, Record<string, unknown>>;
|
||||
expect(provider.other).toBeDefined();
|
||||
@@ -122,10 +182,11 @@ describe("config agent writers", () => {
|
||||
const options = provider["bailian-cli"].options as Record<string, unknown>;
|
||||
expect(options.baseURL).toBe(ANTHROPIC_URL);
|
||||
expect(options.apiKey).toBe("sk-o");
|
||||
expect(options.setCacheKey).toBe(true);
|
||||
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
|
||||
expect(options.setCacheKey).toBeUndefined();
|
||||
expect(
|
||||
(provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"],
|
||||
).toBeDefined();
|
||||
|
||||
// 非 anthropic 端点用 openai-compatible
|
||||
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
expect(
|
||||
(
|
||||
@@ -137,61 +198,103 @@ describe("config agent writers", () => {
|
||||
).toBe("@ai-sdk/openai-compatible");
|
||||
});
|
||||
|
||||
test("openclaw 写入 provider、api 与 primary", () => {
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
|
||||
test("opencode 存在 .jsonc 时写入 .jsonc", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(join(home, ".config", "opencode", "opencode.jsonc"), "{}\n");
|
||||
const summary = opencode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-o",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
expect(summary.paths[0].endsWith("opencode.jsonc")).toBe(true);
|
||||
});
|
||||
|
||||
test("openclaw 不传 contextWindow 时不写该字段", () => {
|
||||
openclaw.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-c",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const config = readJsonAt(".openclaw", "openclaw.json");
|
||||
const models = config.models as Record<string, unknown>;
|
||||
expect(models.mode).toBe("merge");
|
||||
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
|
||||
const bailian = (
|
||||
models.providers as Record<string, Record<string, unknown>>
|
||||
)["bailian-cli"];
|
||||
expect(bailian.api).toBe("openai-completions");
|
||||
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
|
||||
const agents = config.agents as { defaults: { model: { primary: string } } };
|
||||
const entry = (bailian.models as Array<Record<string, unknown>>)[0];
|
||||
expect(entry.id).toBe("qwen3-coder-plus");
|
||||
expect(entry.contextWindow).toBeUndefined();
|
||||
const agents = config.agents as {
|
||||
defaults: { model: { primary: string }; models: Record<string, unknown> };
|
||||
};
|
||||
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
|
||||
|
||||
// anthropic 端点用 anthropic-messages
|
||||
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const config2 = readJsonAt(".openclaw", "openclaw.json");
|
||||
expect(
|
||||
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
|
||||
"bailian-cli"
|
||||
].api,
|
||||
).toBe("anthropic-messages");
|
||||
agents.defaults.models["bailian-cli/qwen3-coder-plus"],
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {
|
||||
test("openclaw 传 contextWindow 时写入该字段;anthropic 端点用 anthropic-messages", () => {
|
||||
openclaw.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-c",
|
||||
model: "qwen3-max",
|
||||
contextWindow: 262144,
|
||||
});
|
||||
const config = readJsonAt(".openclaw", "openclaw.json");
|
||||
const bailian = (
|
||||
(config.models as Record<string, unknown>).providers as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
)["bailian-cli"];
|
||||
expect(bailian.api).toBe("anthropic-messages");
|
||||
expect(
|
||||
(bailian.models as Array<Record<string, unknown>>)[0].contextWindow,
|
||||
).toBe(262144);
|
||||
});
|
||||
|
||||
test("hermes 官方扁平 model.* 结构;anthropic 端点带 api_mode", () => {
|
||||
mkdirSync(join(home, ".hermes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hermes", "config.yaml"),
|
||||
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
|
||||
yaml.stringify({ agent: { max_turns: 5 } }),
|
||||
);
|
||||
|
||||
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
|
||||
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
|
||||
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
|
||||
expect(names).toContain("other");
|
||||
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry.base_url).toBe(OAI_URL);
|
||||
expect(entry.api_key).toBe("sk-h");
|
||||
expect(entry.api_mode).toBe("chat_completions");
|
||||
|
||||
// anthropic 端点用 anthropic_messages
|
||||
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
|
||||
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry2.api_mode).toBe("anthropic_messages");
|
||||
// upsert:bailian-cli 项不重复
|
||||
expect(
|
||||
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
|
||||
).toHaveLength(1);
|
||||
hermes.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-h",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const config = yaml.parse(
|
||||
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
|
||||
);
|
||||
// 合并保留其它顶层键
|
||||
expect(config.agent).toEqual({ max_turns: 5 });
|
||||
expect(config.model).toEqual({
|
||||
default: "qwen3-max",
|
||||
provider: "custom",
|
||||
base_url: ANTHROPIC_URL,
|
||||
api_key: "sk-h",
|
||||
api_mode: "anthropic_messages",
|
||||
});
|
||||
expect(config.custom_providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("codex 写入 config.toml 与 auth.json(cc-switch 对齐结构,合并保留)", () => {
|
||||
// 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并
|
||||
test("hermes OpenAI 兼容端点省略 api_mode", () => {
|
||||
hermes.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-h",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const config = yaml.parse(
|
||||
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
|
||||
);
|
||||
expect(config.model.api_mode).toBeUndefined();
|
||||
expect(config.model.base_url).toBe(OAI_URL);
|
||||
});
|
||||
|
||||
test("codex 官方 env_key 结构;合并保留其它配置", () => {
|
||||
mkdirSync(join(home, ".codex"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".codex", "config.toml"),
|
||||
@@ -200,24 +303,31 @@ describe("config agent writers", () => {
|
||||
"",
|
||||
"[model_providers.other]",
|
||||
'name = "other"',
|
||||
'base_url = "https://other.example.com/v1"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
// 预置 auth.json 无关键,验证合并保留
|
||||
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
|
||||
writeFileSync(
|
||||
join(home, ".codex", "auth.json"),
|
||||
JSON.stringify({ EXISTING: "keep" }),
|
||||
);
|
||||
|
||||
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
|
||||
codex.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-x",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain('model = "qwen3-coder-plus"');
|
||||
expect(toml).toContain('model_reasoning_effort = "high"');
|
||||
expect(toml).toContain("disable_response_storage = true");
|
||||
expect(toml).toContain("[model_providers.bailian-cli]");
|
||||
expect(toml).toContain(`base_url = "${OAI_URL}"`);
|
||||
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
// 合并:保留用户已有的无关配置
|
||||
// 不再包含 cc-switch 专有字段
|
||||
expect(toml).not.toContain("requires_openai_auth");
|
||||
expect(toml).not.toContain("model_reasoning_effort");
|
||||
expect(toml).not.toContain("disable_response_storage");
|
||||
// 合并保留
|
||||
expect(toml).toContain('approval_policy = "on-request"');
|
||||
expect(toml).toContain("[model_providers.other]");
|
||||
|
||||
@@ -226,9 +336,28 @@ describe("config agent writers", () => {
|
||||
expect(auth.EXISTING).toBe("keep");
|
||||
});
|
||||
|
||||
test("codex 尊重 CODEX_HOME", () => {
|
||||
const dir = join(home, "custom-codex");
|
||||
process.env.CODEX_HOME = dir;
|
||||
codex.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-x",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
expect(readFileSync(join(dir, "config.toml"), "utf8")).toContain(
|
||||
'model_provider = "bailian-cli"',
|
||||
);
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(dir, "auth.json"), "utf8")).OPENAI_API_KEY,
|
||||
).toBe("sk-x");
|
||||
});
|
||||
|
||||
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
|
||||
mkdirSync(join(home, ".openclaw"), { recursive: true });
|
||||
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
|
||||
writeFileSync(
|
||||
join(home, ".openclaw", "openclaw.json"),
|
||||
JSON.stringify({ pre: 1 }),
|
||||
);
|
||||
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
|
||||
@@ -236,4 +365,15 @@ describe("config agent writers", () => {
|
||||
);
|
||||
expect(backups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("已有配置解析失败时抛错,不覆盖原文件", () => {
|
||||
mkdirSync(join(home, ".openclaw"), { recursive: true });
|
||||
const path = join(home, ".openclaw", "openclaw.json");
|
||||
writeFileSync(path, "{ this is not valid json");
|
||||
expect(() =>
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" }),
|
||||
).toThrow(/Failed to parse/);
|
||||
// 原文件保持不变
|
||||
expect(readFileSync(path, "utf8")).toBe("{ this is not valid json");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
@@ -11,29 +17,49 @@ import { CONFIG_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: config", () => {
|
||||
test("config show --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"show",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/show|config/i);
|
||||
});
|
||||
|
||||
test("config set --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config list/use --help 正常退出", async () => {
|
||||
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
|
||||
const listResult = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"list",
|
||||
"--help",
|
||||
]);
|
||||
expect(listResult.exitCode, listResult.stderr).toBe(0);
|
||||
expect(listResult.stderr).toMatch(/list|active|profile/i);
|
||||
|
||||
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
|
||||
const useResult = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"use",
|
||||
"--help",
|
||||
]);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(useResult.stderr).toMatch(/use|--name|active/i);
|
||||
});
|
||||
|
||||
test("config ui --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"ui",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
|
||||
});
|
||||
@@ -105,13 +131,21 @@ describe("e2e: config", () => {
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--key|--value|Usage:/i);
|
||||
});
|
||||
|
||||
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"use",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--name|Usage:/i);
|
||||
});
|
||||
@@ -120,7 +154,10 @@ describe("e2e: config", () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n",
|
||||
);
|
||||
const env = { BAILIAN_CONFIG_DIR: configDir };
|
||||
|
||||
const useResult = await runCommandE2e(
|
||||
@@ -129,10 +166,13 @@ describe("e2e: config", () => {
|
||||
env,
|
||||
);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
|
||||
expect(
|
||||
parseStdoutJson<{ active_config?: string }>(useResult.stdout)
|
||||
.active_config,
|
||||
).toBe("dev");
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
|
||||
|
||||
const listResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
@@ -155,17 +195,23 @@ describe("e2e: config", () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n",
|
||||
);
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
expect(
|
||||
parseStdoutJson<{ would_activate?: string }>(result.stdout)
|
||||
.would_activate,
|
||||
).toBe("dev");
|
||||
expect(
|
||||
JSON.parse(readFileSync(configPath, "utf8")).active_config,
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -175,7 +221,10 @@ describe("e2e: config", () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ output: "text" }, null, 2) + "\n",
|
||||
);
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "missing", "--output", "json"],
|
||||
@@ -183,7 +232,9 @@ describe("e2e: config", () => {
|
||||
);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toMatch(/does not exist/);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
expect(
|
||||
JSON.parse(readFileSync(configPath, "utf8")).active_config,
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -246,16 +297,24 @@ describe("e2e: config", () => {
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
|
||||
"https://proxy.example.com/bailian",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
|
||||
"https://proxy.example.com/bailian",
|
||||
);
|
||||
expect(
|
||||
parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url,
|
||||
).toBe("https://proxy.example.com/bailian");
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))
|
||||
.base_url,
|
||||
).toBe("https://proxy.example.com/bailian");
|
||||
|
||||
const invalidResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"base_url",
|
||||
"--value",
|
||||
"ftp://example.com/models",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(invalidResult.exitCode).toBe(2);
|
||||
@@ -295,7 +354,9 @@ describe("e2e: config", () => {
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_text_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
@@ -329,7 +390,9 @@ describe("e2e: config", () => {
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
|
||||
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
|
||||
});
|
||||
|
||||
@@ -347,7 +410,11 @@ describe("e2e: config", () => {
|
||||
});
|
||||
|
||||
test("config agent --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/agent|--base-url|--model/i);
|
||||
});
|
||||
@@ -367,6 +434,51 @@ describe("e2e: config", () => {
|
||||
expect(stderr).toMatch(/--api-key|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent 缺少 --base-url 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--base-url|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent 缺少 --model 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--model|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent 缺少 --agent 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--agent|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
@@ -414,7 +526,9 @@ describe("e2e: config", () => {
|
||||
api_key?: string;
|
||||
}>(stdout);
|
||||
expect(data.agent).toBe("claude-code");
|
||||
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
|
||||
expect(data.base_url).toBe(
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
expect(data.model).toBe("qwen3-max");
|
||||
expect(stdout).not.toContain("sk-secret-placeholder");
|
||||
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
|
||||
@@ -423,7 +537,7 @@ describe("e2e: config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => {
|
||||
test("config agent codex 写入 config.toml 与 auth.json(官方 env_key 结构)", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
@@ -445,16 +559,19 @@ describe("e2e: config", () => {
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
|
||||
expect(toml).not.toContain("requires_openai_auth");
|
||||
const auth = JSON.parse(
|
||||
readFileSync(join(home, ".codex", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent hermes 写入 custom_providers 结构", async () => {
|
||||
test("config agent hermes 写入官方扁平 model.* 结构", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
@@ -465,19 +582,22 @@ describe("e2e: config", () => {
|
||||
"--agent",
|
||||
"hermes",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--api-key",
|
||||
"sk-hermes-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-max",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
|
||||
expect(yamlText).toContain("custom_providers");
|
||||
expect(yamlText).toContain("bailian-cli");
|
||||
expect(yamlText).toContain("api_mode: chat_completions");
|
||||
const yamlText = readFileSync(
|
||||
join(home, ".hermes", "config.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(yamlText).toContain("provider: custom");
|
||||
expect(yamlText).toContain("api_mode: anthropic_messages");
|
||||
expect(yamlText).not.toContain("custom_providers");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ Index: [index.md](index.md)
|
||||
| `--base-url <url>` | string | yes | API base URL |
|
||||
| `--api-key <key>` | string | yes | API key |
|
||||
| `--model <model>` | string | yes | Default model name |
|
||||
| `--context-window <tokens>` | number | no | Context window in tokens (openclaw only; omit to use the agent default) |
|
||||
|
||||
#### Examples
|
||||
|
||||
|
||||
Reference in New Issue
Block a user