From 3ac3bbaf95289fb595f8d6a348b2516144be5118 Mon Sep 17 00:00:00 2001 From: "lisheng.lisheng" Date: Wed, 29 Jul 2026 11:36:34 +0800 Subject: [PATCH 1/2] feat(config-agent): add --restore to roll back agent configs from latest backup --- .../src/commands/config/agent/index.ts | 105 +++++++- .../config/agent/writers/claude-code.ts | 18 +- .../commands/config/agent/writers/codex.ts | 24 +- .../commands/config/agent/writers/hermes.ts | 17 +- .../commands/config/agent/writers/openclaw.ts | 23 +- .../commands/config/agent/writers/opencode.ts | 19 +- .../config/agent/writers/qwen-code.ts | 33 ++- .../commands/config/agent/writers/utils.ts | 77 +++++- .../commands/tests/e2e/config.e2e.test.ts | 255 +++++++++++++++--- skills/bailian-cli/reference/config.md | 17 +- 10 files changed, 501 insertions(+), 87 deletions(-) diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index 54bb55c..0bb6f6f 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -1,9 +1,20 @@ import { platform } from "os"; -import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core"; +import { + defineCommand, + detectOutputFormat, + maskToken, + BailianError, + ExitCode, + type FlagsDef, +} from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts"; import { decodeTokenPlanKey } from "./decode-key.ts"; -import { resolveRegionBaseUrl } from "./writers/utils.ts"; +import { + resolveRegionBaseUrl, + findLatestBackup, + restoreLatestBackup, +} from "./writers/utils.ts"; const FLAGS = { agent: { @@ -39,12 +50,12 @@ const FLAGS = { type: "string", valueHint: "", description: "Default model name", - required: true, }, contextWindow: { type: "number", valueHint: "", - description: "OpenClaw only: model context window in tokens (default: 256000)", + description: + "OpenClaw only: model context window in tokens (default: 256000)", }, wireApi: { type: "string", @@ -53,38 +64,106 @@ const FLAGS = { 'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0', choices: ["chat", "responses"], }, + restore: { + type: "switch", + description: + "Restore the agent's config files from the latest .bak backup created by this command", + }, } satisfies FlagsDef; export default defineCommand({ description: "Configure a coding agent to use DashScope API", auth: "none", usageArgs: - "--agent (--base-url | --region ) (--api-key | --key ) --model ", + "--agent ((--base-url | --region ) (--api-key | --key ) --model | --restore)", flags: FLAGS, exampleArgs: [ "--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max", "--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus", "--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus", + "--agent claude-code --restore", ], validate(flags) { - if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required"; - if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive"; - if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required"; - if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive"; + if (flags.restore) { + const writeFlags = [ + flags.baseUrl, + flags.region, + flags.apiKey, + flags.key, + flags.model, + ]; + if (writeFlags.some((value) => value !== undefined)) { + return "--restore cannot be combined with --base-url/--region/--api-key/--key/--model"; + } + return undefined; + } + if (!flags.model) return "--model is required"; + if (!flags.baseUrl && !flags.region) + return "one of --base-url or --region is required"; + if (flags.baseUrl && flags.region) + return "--base-url and --region are mutually exclusive"; + if (!flags.apiKey && !flags.key) + return "one of --api-key or --key is required"; + if (flags.apiKey && flags.key) + return "--api-key and --key are mutually exclusive"; return undefined; }, async run(ctx) { const { settings, flags } = ctx; const agentName = flags.agent; - const { model, contextWindow, wireApi } = flags; + const { contextWindow, wireApi } = flags; + const agentDef = AGENTS[agentName]; + const format = detectOutputFormat(settings.output); + + // --restore: roll each managed config file back to its newest .bak sibling. + if (flags.restore) { + const paths = agentDef.configPaths(); + + if (settings.dryRun) { + emitResult( + { + agent: agentName, + label: agentDef.label, + restore: paths.map((path) => ({ + path, + backup: findLatestBackup(path) ?? null, + })), + }, + format, + ); + return; + } + + const results = paths.map((path) => restoreLatestBackup(path)); + if (results.every((result) => !result.backupPath)) { + throw new BailianError( + `No backups found for ${agentDef.label}.`, + ExitCode.GENERAL, + "Backups are created as .bak. next to each config file when this command writes it.", + ); + } + + if (!settings.quiet) { + emitBare(`${agentDef.label} config restored from backup.`); + for (const result of results) { + if (result.backupPath) + emitBare(` Restored: ${result.path} <- ${result.backupPath}`); + else emitBare(` Skipped (no backup): ${result.path}`); + } + } + return; + } + + // Write mode — validate() guarantees these flags are present. + const model = flags.model!; // --region is a Token Plan convenience: convert it into a base URL and use // it exactly as --base-url would be. - const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!; + const baseUrl = flags.region + ? resolveRegionBaseUrl(flags.region) + : flags.baseUrl!; // --key carries the web console's obfuscated form; decode it up front so // even --dry-run validates the token. const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!; - const agentDef = AGENTS[agentName]; - const format = detectOutputFormat(settings.output); // Hermes has no native Windows support. if (agentName === "hermes" && platform() === "win32") { diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts index cd4a990..61fa37c 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -9,20 +9,28 @@ import { } from "./utils.ts"; /** Fill a tier/default model env only when the user has not set it yet. */ -function setModelEnvIfAbsent(env: Record, key: string, model: string): void { +function setModelEnvIfAbsent( + env: Record, + key: string, + model: string, +): void { const current = env[key]; if (current === undefined || current.trim() === "") { env[key] = model; } } +function configPaths(): string[] { + // Claude Code honors CLAUDE_CONFIG_DIR for its settings location. + const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); + return [join(configDir, "settings.json"), join(homedir(), ".claude.json")]; +} + export default { label: "Claude Code", + configPaths, write({ baseUrl, apiKey, model }) { - // Claude Code honors CLAUDE_CONFIG_DIR for its settings location. - const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); - const settingsPath = join(configDir, "settings.json"); - const onboardingPath = join(homedir(), ".claude.json"); + const [settingsPath, onboardingPath] = configPaths(); const warnings: string[] = []; const resolved = resolveClaudeCodeBaseUrl(baseUrl); diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts index bb5c106..bf978c5 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -2,14 +2,28 @@ 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 { + backup, + readJson, + writeJsonAtomic, + writeTextAtomic, + type AgentDef, +} from "./utils.ts"; const PROVIDER_KEY = "bailian-cli"; +function configPaths(): string[] { + return [ + join(homedir(), ".codex", "config.toml"), + join(homedir(), ".codex", "auth.json"), + ]; +} + export default { label: "Codex", + configPaths, write({ baseUrl, apiKey, model, wireApi: wireApiParam }) { - const configPath = join(homedir(), ".codex", "config.toml"); + const [configPath, authPath] = configPaths(); const warnings: string[] = []; // config.toml — merge into existing config so unrelated settings @@ -18,7 +32,10 @@ export default { let config: Record = {}; if (existsSync(configPath)) { try { - config = parseToml(readFileSync(configPath, "utf-8")) as Record; + config = parseToml(readFileSync(configPath, "utf-8")) as Record< + string, + unknown + >; } catch { config = {}; } @@ -57,7 +74,6 @@ export default { writeTextAtomic(configPath, stringifyToml(config) + "\n"); // auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset. - const authPath = join(homedir(), ".codex", "auth.json"); backup(authPath); const auth = readJson(authPath); auth.OPENAI_API_KEY = apiKey; diff --git a/packages/commands/src/commands/config/agent/writers/hermes.ts b/packages/commands/src/commands/config/agent/writers/hermes.ts index 73ae519..e8de894 100644 --- a/packages/commands/src/commands/config/agent/writers/hermes.ts +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -2,19 +2,30 @@ 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"; +import { + backup, + writeTextAtomic, + isAnthropicEndpoint, + type AgentDef, +} from "./utils.ts"; + +function configPaths(): string[] { + return [join(homedir(), ".hermes", "config.yaml")]; +} export default { label: "Hermes Agent", + configPaths, write({ baseUrl, apiKey, model }) { - const configPath = join(homedir(), ".hermes", "config.yaml"); + const [configPath] = configPaths(); backup(configPath); let config: Record = {}; if (existsSync(configPath)) { try { - config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record; + config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? + {}) as Record; } catch { config = {}; } diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 57d44c3..33cf227 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -1,6 +1,12 @@ 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"; // Safe default when --context-window is not given: most Model Studio models // offer ≥256K context; users can raise it per model via the flag. @@ -8,17 +14,24 @@ const DEFAULT_CONTEXT_WINDOW = 256000; const PROVIDER_ID = "bailian-cli"; +function configPaths(): string[] { + return [join(homedir(), ".openclaw", "openclaw.json")]; +} + function readPrimary(defaults: Record): string | undefined { const model = defaults.model; if (!model || typeof model !== "object") return undefined; const primary = (model as Record).primary; - return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined; + return typeof primary === "string" && primary.trim() !== "" + ? primary.trim() + : undefined; } export default { label: "OpenClaw", + configPaths, write({ baseUrl, apiKey, model, contextWindow }) { - const configPath = join(homedir(), ".openclaw", "openclaw.json"); + const [configPath] = configPaths(); const warnings: string[] = []; const modelRef = `${PROVIDER_ID}/${model}`; @@ -30,7 +43,9 @@ export default { const models = (config.models ?? {}) as Record; models.mode = "merge"; const providers = (models.providers ?? {}) as Record; - const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions"; + const api = isAnthropicEndpoint(baseUrl) + ? "anthropic-messages" + : "openai-completions"; providers[PROVIDER_ID] = { baseUrl, apiKey, diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts index cc64b49..7bdc396 100644 --- a/packages/commands/src/commands/config/agent/writers/opencode.ts +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -1,11 +1,22 @@ import { homedir } from "os"; import { join } from "path"; -import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; +import { + backup, + readJsonc, + writeJsonAtomic, + isAnthropicEndpoint, + type AgentDef, +} from "./utils.ts"; + +function configPaths(): string[] { + return [join(homedir(), ".config", "opencode", "opencode.json")]; +} export default { label: "OpenCode", + configPaths, write({ baseUrl, apiKey, model }) { - const configPath = join(homedir(), ".config", "opencode", "opencode.json"); + const [configPath] = configPaths(); // opencode.json is JSONC — tolerate comments and trailing commas on read. backup(configPath); @@ -14,7 +25,9 @@ export default { if (!config.$schema) config.$schema = "https://opencode.ai/config.json"; const provider = (config.provider ?? {}) as Record; - 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", diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts index 42e6ae7..cbe6669 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -1,6 +1,12 @@ 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 = "DASHSCOPE_API_KEY"; @@ -8,6 +14,10 @@ function displayName(model: string): string { return `[Bailian] ${model}`; } +function configPaths(): string[] { + return [join(homedir(), ".qwen", "settings.json")]; +} + /** Entries we previously wrote, or still own via envKey / display brand. */ function isBailianCliEntry(entry: Record): boolean { if (entry.envKey === ENV_KEY) return true; @@ -35,8 +45,9 @@ function isBailianCliEntry(entry: Record): boolean { */ export default { label: "Qwen Code", + configPaths, write({ baseUrl, apiKey, model }) { - const settingsPath = join(homedir(), ".qwen", "settings.json"); + const [settingsPath] = configPaths(); const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai"; const warnings: string[] = []; @@ -66,15 +77,23 @@ export default { string, Array> >; - const entries = (providers[protocol] ?? []) as Array>; - const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model); - const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model); + const entries = (providers[protocol] ?? []) as Array< + Record + >; + const owned = entries.find( + (entry) => isBailianCliEntry(entry) && entry.id === model, + ); + const conflicting = entries.find( + (entry) => !isBailianCliEntry(entry) && entry.id === model, + ); if (owned) { owned.baseUrl = baseUrl; owned.envKey = ENV_KEY; - const currentName = typeof owned.name === "string" ? owned.name.trim() : ""; - if (!currentName || currentName === "bailian-cli") owned.name = displayName(model); + const currentName = + typeof owned.name === "string" ? owned.name.trim() : ""; + if (!currentName || currentName === "bailian-cli") + owned.name = displayName(model); } else if (conflicting) { const existingName = typeof conflicting.name === "string" && conflicting.name.length > 0 diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index 32f3d04..0f6ea90 100644 --- a/packages/commands/src/commands/config/agent/writers/utils.ts +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -1,5 +1,13 @@ -import { dirname } from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs"; +import { basename, dirname, join } from "path"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + renameSync, + copyFileSync, + readdirSync, +} from "fs"; import { BailianError, ExitCode } from "bailian-cli-core"; /** Parameters shared by every agent writer. */ @@ -24,6 +32,8 @@ export interface WriteSummary { /** An agent configuration writer: a human label plus a `write` that applies it. */ export interface AgentDef { label: string; + /** Config files this writer manages, in write order. Used by `--restore`. */ + configPaths(): string[]; write(params: WriteParams): WriteSummary; } @@ -62,7 +72,11 @@ export function stripJsonc(text: string): string { } if (char === "/" && next === "*") { index += 2; - while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1; + while ( + index < text.length && + !(text[index] === "*" && text[index + 1] === "/") + ) + index += 1; index += 2; continue; } @@ -92,7 +106,11 @@ export function stripJsonc(text: string): string { if (char === '"') inString = true; if (char === ",") { let lookahead = index + 1; - while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1; + while ( + lookahead < uncommented.length && + /\s/.test(uncommented[lookahead]) + ) + lookahead += 1; if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") { index += 1; continue; @@ -118,7 +136,10 @@ export function readJson(path: string): Record { export function readJsonc(path: string): Record { if (!existsSync(path)) return {}; try { - return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record; + return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record< + string, + unknown + >; } catch { return {}; } @@ -147,6 +168,42 @@ export function backup(path: string): void { copyFileSync(path, `${path}.bak.${timestamp}`); } +/** Locate the newest `.bak.` sibling created by {@link backup}, if any. */ +export function findLatestBackup(path: string): string | undefined { + const dir = dirname(path); + if (!existsSync(dir)) return undefined; + const prefix = basename(path) + ".bak."; + let latest: { name: string; timestamp: number } | undefined; + for (const entry of readdirSync(dir)) { + if (!entry.startsWith(prefix)) continue; + const suffix = entry.slice(prefix.length); + if (!/^\d+$/.test(suffix)) continue; + const timestamp = Number(suffix); + if (!latest || timestamp > latest.timestamp) + latest = { name: entry, timestamp }; + } + return latest ? join(dir, latest.name) : undefined; +} + +/** Outcome of restoring one config file: which backup was used, if any. */ +export interface RestoreResult { + path: string; + backupPath?: string; +} + +/** + * Restore `path` from its newest `.bak.` backup (atomically, keeping the + * backup in place so the operation stays repeatable). No-op when no backup exists. + */ +export function restoreLatestBackup(path: string): RestoreResult { + const backupPath = findLatestBackup(path); + if (!backupPath) return { path }; + const tmp = path + ".tmp"; + copyFileSync(backupPath, tmp); + renameSync(tmp, path); + return { path, backupPath }; +} + /** Whether a base URL targets the Anthropic-messages compatible endpoint. */ export function isAnthropicEndpoint(baseUrl: string): boolean { return baseUrl.includes("/apps/anthropic"); @@ -168,7 +225,10 @@ export function resolveClaudeCodeBaseUrl(baseUrl: string): { } if (trimmed.includes("/compatible-mode")) { - const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic"); + const rewritten = trimmed.replace( + /\/compatible-mode(?:\/v\d+)?/, + "/apps/anthropic", + ); return { url: rewritten, rewrittenFrom: baseUrl.trim() }; } @@ -179,7 +239,10 @@ export function resolveClaudeCodeBaseUrl(baseUrl: string): { host.includes("dashscope") || host.includes("maas.aliyuncs.com") || host.includes("token-plan"); - if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) { + if ( + isDashScopeHost && + (parsed.pathname === "/" || parsed.pathname === "") + ) { return { url: `${parsed.origin}/apps/anthropic`, rewrittenFrom: baseUrl.trim(), diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index e9b1bdb..13d91e9 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,4 +1,11 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, +} from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; @@ -11,29 +18,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 +132,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 +155,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 +167,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 +196,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 +222,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 +233,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 +298,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", - ); - expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe( - "https://proxy.example.com", - ); + expect( + parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url, + ).toBe("https://proxy.example.com"); + expect( + JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) + .base_url, + ).toBe("https://proxy.example.com"); 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); @@ -317,7 +377,9 @@ describe("e2e: config", () => { const data = parseStdoutJson<{ would_set?: { default_image_to_video_model?: string }; }>(stdout); - expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v"); + expect(data.would_set?.default_image_to_video_model).toBe( + "happyhorse-1.1-i2v", + ); }); test("config set --dry-run 支持参考生视频默认模型别名", async () => { @@ -336,7 +398,9 @@ describe("e2e: config", () => { const data = parseStdoutJson<{ would_set?: { default_reference_to_video_model?: string }; }>(stdout); - expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v"); + expect(data.would_set?.default_reference_to_video_model).toBe( + "happyhorse-1.1-r2v", + ); }); test("config set --dry-run 展示归一化后的 Base URL", async () => { @@ -369,7 +433,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"); }); @@ -387,7 +453,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); }); @@ -468,7 +538,9 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ agent?: string; api_key?: string }>(stdout); + const data = parseStdoutJson<{ agent?: string; api_key?: string }>( + stdout, + ); expect(data.agent).toBe("claude-code"); // 解码后的真实 key 不得明文出现,且脱敏值非空 expect(stdout).not.toContain("sk-e2e-key-placeholder"); @@ -502,7 +574,9 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ agent?: string; base_url?: string }>(stdout); + const data = parseStdoutJson<{ agent?: string; base_url?: string }>( + stdout, + ); expect(data.agent).toBe("qwen-code"); expect(data.base_url).toBe( "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", @@ -512,6 +586,108 @@ describe("e2e: config", () => { } }); + test("config agent --restore 与写入类 flag 同传时报用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "agent", + "--agent", + "claude-code", + "--restore", + "--model", + "qwen3-max", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--restore cannot be combined/i); + }); + + test("config agent --restore 无备份时报错 (1)", async () => { + const home = mkdtempSync(join(tmpdir(), "bl-config-agent-restore-none-")); + try { + const { stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + ["config", "agent", "--agent", "hermes", "--restore"], + { HOME: home }, + ); + expect(exitCode).toBe(1); + expect(stderr).toMatch(/No backups found/i); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("config agent --restore 还原到写入前的配置", async () => { + const home = mkdtempSync(join(tmpdir(), "bl-config-agent-restore-")); + try { + const settingsPath = join(home, ".qwen", "settings.json"); + const original = { env: { KEEP_ME: "original" } }; + mkdirSync(join(home, ".qwen"), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(original, null, 2) + "\n"); + + // 写入:会先备份原文件再合并写入 + const writeResult = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "agent", + "--agent", + "qwen-code", + "--base-url", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "--api-key", + "sk-restore-placeholder", + "--model", + "qwen3-coder-plus", + ], + { HOME: home }, + ); + expect(writeResult.exitCode, writeResult.stderr).toBe(0); + const written = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(written.modelProviders).toBeDefined(); + + // 还原:内容回到写入前的原始配置 + const restoreResult = await runCommandE2e( + CONFIG_ROUTES, + ["config", "agent", "--agent", "qwen-code", "--restore"], + { HOME: home }, + ); + expect(restoreResult.exitCode, restoreResult.stderr).toBe(0); + expect(restoreResult.stdout).toMatch(/Restored:/); + expect(JSON.parse(readFileSync(settingsPath, "utf-8"))).toEqual(original); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("config agent --restore --dry-run 列出备份计划不改文件", async () => { + const home = mkdtempSync(join(tmpdir(), "bl-config-agent-restore-dry-")); + try { + const { stdout, stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "agent", + "--agent", + "opencode", + "--restore", + "--dry-run", + "--output", + "json", + ], + { HOME: home }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + agent?: string; + restore?: Array<{ path: string; backup: string | null }>; + }>(stdout); + expect(data.agent).toBe("opencode"); + expect(Array.isArray(data.restore)).toBe(true); + expect(data.restore?.[0]?.backup).toBeNull(); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("config agent --base-url 与 --region 同传时报用法错误 (2)", async () => { const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", @@ -593,7 +769,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); @@ -628,7 +806,9 @@ describe("e2e: config", () => { expect(toml).toContain("requires_openai_auth = true"); // 默认即 responses(新版 Codex 已不支持 chat) expect(toml).toContain('wire_api = "responses"'); - const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8")); + 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 }); @@ -655,10 +835,15 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8"); + const yamlText = readFileSync( + join(home, ".hermes", "config.yaml"), + "utf8", + ); expect(yamlText).toContain("default: qwen3-coder-plus"); expect(yamlText).toContain("provider: custom"); - expect(yamlText).toContain("base_url: https://dashscope.aliyuncs.com/compatible-mode/v1"); + expect(yamlText).toContain( + "base_url: https://dashscope.aliyuncs.com/compatible-mode/v1", + ); expect(yamlText).toContain("api_key: sk-hermes-placeholder"); // OpenAI 兼容端点不写 api_mode expect(yamlText).not.toContain("api_mode"); diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 50403b9..0d5b79c 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -20,11 +20,11 @@ Index: [index.md](index.md) ### `bl config agent` -| Field | Value | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **Name** | `config agent` | -| **Description** | Configure a coding agent to use DashScope API | -| **Usage** | `bl config agent --agent (--base-url \| --region ) (--api-key \| --key ) --model ` | +| Field | Value | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `config agent` | +| **Description** | Configure a coding agent to use DashScope API | +| **Usage** | `bl config agent --agent ((--base-url \| --region ) (--api-key \| --key ) --model \| --restore)` | #### Flags @@ -35,9 +35,10 @@ Index: [index.md](index.md) | `--region ` | string | no | Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only | | `--api-key ` | string | no | API key | | `--key ` | string | no | Obfuscated API key from the web console (starts with "o1\_"); decoded into --api-key | -| `--model ` | string | yes | Default model name | +| `--model ` | string | no | Default model name | | `--context-window ` | number | no | OpenClaw only: model context window in tokens (default: 256000) | | `--wire-api ` | string | no | Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0 | +| `--restore` | switch | no | Restore the agent's config files from the latest .bak backup created by this command | #### Examples @@ -53,6 +54,10 @@ bl config agent --agent qwen-code --base-url https://dashscope.aliyuncs.com/comp bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus ``` +```bash +bl config agent --agent claude-code --restore +``` + ### `bl config list` | Field | Value | From aa3c44ee79a7763718ae4a3597dcc335ab43d034 Mon Sep 17 00:00:00 2001 From: "lisheng.lisheng" Date: Wed, 29 Jul 2026 11:38:53 +0800 Subject: [PATCH 2/2] style: apply vp check --fix formatting --- .../src/commands/config/agent/index.ts | 36 +--- .../config/agent/writers/claude-code.ts | 6 +- .../commands/config/agent/writers/codex.ts | 18 +- .../commands/config/agent/writers/hermes.ts | 10 +- .../commands/config/agent/writers/openclaw.ts | 16 +- .../commands/config/agent/writers/opencode.ts | 12 +- .../config/agent/writers/qwen-code.ts | 26 +-- .../commands/config/agent/writers/utils.ts | 30 +--- .../commands/tests/e2e/config.e2e.test.ts | 164 ++++-------------- 9 files changed, 68 insertions(+), 250 deletions(-) diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index 0bb6f6f..18ae02d 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -10,11 +10,7 @@ import { import { emitResult, emitBare } from "bailian-cli-runtime"; import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts"; import { decodeTokenPlanKey } from "./decode-key.ts"; -import { - resolveRegionBaseUrl, - findLatestBackup, - restoreLatestBackup, -} from "./writers/utils.ts"; +import { resolveRegionBaseUrl, findLatestBackup, restoreLatestBackup } from "./writers/utils.ts"; const FLAGS = { agent: { @@ -54,8 +50,7 @@ const FLAGS = { contextWindow: { type: "number", valueHint: "", - description: - "OpenClaw only: model context window in tokens (default: 256000)", + description: "OpenClaw only: model context window in tokens (default: 256000)", }, wireApi: { type: "string", @@ -85,27 +80,17 @@ export default defineCommand({ ], validate(flags) { if (flags.restore) { - const writeFlags = [ - flags.baseUrl, - flags.region, - flags.apiKey, - flags.key, - flags.model, - ]; + const writeFlags = [flags.baseUrl, flags.region, flags.apiKey, flags.key, flags.model]; if (writeFlags.some((value) => value !== undefined)) { return "--restore cannot be combined with --base-url/--region/--api-key/--key/--model"; } return undefined; } if (!flags.model) return "--model is required"; - if (!flags.baseUrl && !flags.region) - return "one of --base-url or --region is required"; - if (flags.baseUrl && flags.region) - return "--base-url and --region are mutually exclusive"; - if (!flags.apiKey && !flags.key) - return "one of --api-key or --key is required"; - if (flags.apiKey && flags.key) - return "--api-key and --key are mutually exclusive"; + if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required"; + if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive"; + if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required"; + if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive"; return undefined; }, async run(ctx) { @@ -146,8 +131,7 @@ export default defineCommand({ if (!settings.quiet) { emitBare(`${agentDef.label} config restored from backup.`); for (const result of results) { - if (result.backupPath) - emitBare(` Restored: ${result.path} <- ${result.backupPath}`); + if (result.backupPath) emitBare(` Restored: ${result.path} <- ${result.backupPath}`); else emitBare(` Skipped (no backup): ${result.path}`); } } @@ -158,9 +142,7 @@ export default defineCommand({ const model = flags.model!; // --region is a Token Plan convenience: convert it into a base URL and use // it exactly as --base-url would be. - const baseUrl = flags.region - ? resolveRegionBaseUrl(flags.region) - : flags.baseUrl!; + const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!; // --key carries the web console's obfuscated form; decode it up front so // even --dry-run validates the token. const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!; diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts index 61fa37c..f662192 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -9,11 +9,7 @@ import { } from "./utils.ts"; /** Fill a tier/default model env only when the user has not set it yet. */ -function setModelEnvIfAbsent( - env: Record, - key: string, - model: string, -): void { +function setModelEnvIfAbsent(env: Record, key: string, model: string): void { const current = env[key]; if (current === undefined || current.trim() === "") { env[key] = model; diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts index bf978c5..c92081d 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -2,21 +2,12 @@ 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 { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts"; const PROVIDER_KEY = "bailian-cli"; function configPaths(): string[] { - return [ - join(homedir(), ".codex", "config.toml"), - join(homedir(), ".codex", "auth.json"), - ]; + return [join(homedir(), ".codex", "config.toml"), join(homedir(), ".codex", "auth.json")]; } export default { @@ -32,10 +23,7 @@ export default { let config: Record = {}; if (existsSync(configPath)) { try { - config = parseToml(readFileSync(configPath, "utf-8")) as Record< - string, - unknown - >; + config = parseToml(readFileSync(configPath, "utf-8")) as Record; } catch { config = {}; } diff --git a/packages/commands/src/commands/config/agent/writers/hermes.ts b/packages/commands/src/commands/config/agent/writers/hermes.ts index e8de894..a5a5cca 100644 --- a/packages/commands/src/commands/config/agent/writers/hermes.ts +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -2,12 +2,7 @@ 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"; +import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; function configPaths(): string[] { return [join(homedir(), ".hermes", "config.yaml")]; @@ -24,8 +19,7 @@ export default { let config: Record = {}; if (existsSync(configPath)) { try { - config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? - {}) as Record; + config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record; } catch { config = {}; } diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 33cf227..c1b7236 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -1,12 +1,6 @@ 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"; // Safe default when --context-window is not given: most Model Studio models // offer ≥256K context; users can raise it per model via the flag. @@ -22,9 +16,7 @@ function readPrimary(defaults: Record): string | undefined { const model = defaults.model; if (!model || typeof model !== "object") return undefined; const primary = (model as Record).primary; - return typeof primary === "string" && primary.trim() !== "" - ? primary.trim() - : undefined; + return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined; } export default { @@ -43,9 +35,7 @@ export default { const models = (config.models ?? {}) as Record; models.mode = "merge"; const providers = (models.providers ?? {}) as Record; - const api = isAnthropicEndpoint(baseUrl) - ? "anthropic-messages" - : "openai-completions"; + const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions"; providers[PROVIDER_ID] = { baseUrl, apiKey, diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts index 7bdc396..dc6c706 100644 --- a/packages/commands/src/commands/config/agent/writers/opencode.ts +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -1,12 +1,6 @@ import { homedir } from "os"; import { join } from "path"; -import { - backup, - readJsonc, - writeJsonAtomic, - isAnthropicEndpoint, - type AgentDef, -} from "./utils.ts"; +import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; function configPaths(): string[] { return [join(homedir(), ".config", "opencode", "opencode.json")]; @@ -25,9 +19,7 @@ export default { if (!config.$schema) config.$schema = "https://opencode.ai/config.json"; const provider = (config.provider ?? {}) as Record; - 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", diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts index cbe6669..9f5e0f4 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -1,12 +1,6 @@ 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 = "DASHSCOPE_API_KEY"; @@ -77,23 +71,15 @@ export default { string, Array> >; - const entries = (providers[protocol] ?? []) as Array< - Record - >; - const owned = entries.find( - (entry) => isBailianCliEntry(entry) && entry.id === model, - ); - const conflicting = entries.find( - (entry) => !isBailianCliEntry(entry) && entry.id === model, - ); + const entries = (providers[protocol] ?? []) as Array>; + const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model); + const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model); if (owned) { owned.baseUrl = baseUrl; owned.envKey = ENV_KEY; - const currentName = - typeof owned.name === "string" ? owned.name.trim() : ""; - if (!currentName || currentName === "bailian-cli") - owned.name = displayName(model); + const currentName = typeof owned.name === "string" ? owned.name.trim() : ""; + if (!currentName || currentName === "bailian-cli") owned.name = displayName(model); } else if (conflicting) { const existingName = typeof conflicting.name === "string" && conflicting.name.length > 0 diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index 0f6ea90..bb8162d 100644 --- a/packages/commands/src/commands/config/agent/writers/utils.ts +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -72,11 +72,7 @@ export function stripJsonc(text: string): string { } if (char === "/" && next === "*") { index += 2; - while ( - index < text.length && - !(text[index] === "*" && text[index + 1] === "/") - ) - index += 1; + while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1; index += 2; continue; } @@ -106,11 +102,7 @@ export function stripJsonc(text: string): string { if (char === '"') inString = true; if (char === ",") { let lookahead = index + 1; - while ( - lookahead < uncommented.length && - /\s/.test(uncommented[lookahead]) - ) - lookahead += 1; + while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1; if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") { index += 1; continue; @@ -136,10 +128,7 @@ export function readJson(path: string): Record { export function readJsonc(path: string): Record { if (!existsSync(path)) return {}; try { - return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record< - string, - unknown - >; + return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record; } catch { return {}; } @@ -179,8 +168,7 @@ export function findLatestBackup(path: string): string | undefined { const suffix = entry.slice(prefix.length); if (!/^\d+$/.test(suffix)) continue; const timestamp = Number(suffix); - if (!latest || timestamp > latest.timestamp) - latest = { name: entry, timestamp }; + if (!latest || timestamp > latest.timestamp) latest = { name: entry, timestamp }; } return latest ? join(dir, latest.name) : undefined; } @@ -225,10 +213,7 @@ export function resolveClaudeCodeBaseUrl(baseUrl: string): { } if (trimmed.includes("/compatible-mode")) { - const rewritten = trimmed.replace( - /\/compatible-mode(?:\/v\d+)?/, - "/apps/anthropic", - ); + const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic"); return { url: rewritten, rewrittenFrom: baseUrl.trim() }; } @@ -239,10 +224,7 @@ export function resolveClaudeCodeBaseUrl(baseUrl: string): { host.includes("dashscope") || host.includes("maas.aliyuncs.com") || host.includes("token-plan"); - if ( - isDashScopeHost && - (parsed.pathname === "/" || parsed.pathname === "") - ) { + if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) { return { url: `${parsed.origin}/apps/anthropic`, rewrittenFrom: baseUrl.trim(), diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 13d91e9..95ca4da 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,11 +1,4 @@ -import { - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, - existsSync, -} from "fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; @@ -18,49 +11,29 @@ 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); }); @@ -132,21 +105,13 @@ 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); }); @@ -155,10 +120,7 @@ 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( @@ -167,13 +129,10 @@ describe("e2e: config", () => { env, ); expect(useResult.exitCode, useResult.stderr).toBe(0); - expect( - parseStdoutJson<{ active_config?: string }>(useResult.stdout) - .active_config, - ).toBe("dev"); - expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe( + expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe( "dev", ); + expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev"); const listResult = await runCommandE2e( CONFIG_ROUTES, @@ -196,23 +155,17 @@ 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 }); } @@ -222,10 +175,7 @@ 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"], @@ -233,9 +183,7 @@ 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 }); } @@ -298,24 +246,16 @@ 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"); - expect( - JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) - .base_url, - ).toBe("https://proxy.example.com"); + expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe( + "https://proxy.example.com", + ); + expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe( + "https://proxy.example.com", + ); 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); @@ -377,9 +317,7 @@ describe("e2e: config", () => { const data = parseStdoutJson<{ would_set?: { default_image_to_video_model?: string }; }>(stdout); - expect(data.would_set?.default_image_to_video_model).toBe( - "happyhorse-1.1-i2v", - ); + expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v"); }); test("config set --dry-run 支持参考生视频默认模型别名", async () => { @@ -398,9 +336,7 @@ describe("e2e: config", () => { const data = parseStdoutJson<{ would_set?: { default_reference_to_video_model?: string }; }>(stdout); - expect(data.would_set?.default_reference_to_video_model).toBe( - "happyhorse-1.1-r2v", - ); + expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v"); }); test("config set --dry-run 展示归一化后的 Base URL", async () => { @@ -433,9 +369,7 @@ 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"); }); @@ -453,11 +387,7 @@ 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); }); @@ -538,9 +468,7 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ agent?: string; api_key?: string }>( - stdout, - ); + const data = parseStdoutJson<{ agent?: string; api_key?: string }>(stdout); expect(data.agent).toBe("claude-code"); // 解码后的真实 key 不得明文出现,且脱敏值非空 expect(stdout).not.toContain("sk-e2e-key-placeholder"); @@ -574,9 +502,7 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ agent?: string; base_url?: string }>( - stdout, - ); + const data = parseStdoutJson<{ agent?: string; base_url?: string }>(stdout); expect(data.agent).toBe("qwen-code"); expect(data.base_url).toBe( "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", @@ -663,16 +589,7 @@ describe("e2e: config", () => { try { const { stdout, stderr, exitCode } = await runCommandE2e( CONFIG_ROUTES, - [ - "config", - "agent", - "--agent", - "opencode", - "--restore", - "--dry-run", - "--output", - "json", - ], + ["config", "agent", "--agent", "opencode", "--restore", "--dry-run", "--output", "json"], { HOME: home }, ); expect(exitCode, stderr).toBe(0); @@ -769,9 +686,7 @@ 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); @@ -806,9 +721,7 @@ describe("e2e: config", () => { expect(toml).toContain("requires_openai_auth = true"); // 默认即 responses(新版 Codex 已不支持 chat) expect(toml).toContain('wire_api = "responses"'); - const auth = JSON.parse( - readFileSync(join(home, ".codex", "auth.json"), "utf8"), - ); + 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 }); @@ -835,15 +748,10 @@ describe("e2e: config", () => { { HOME: home }, ); expect(exitCode, stderr).toBe(0); - const yamlText = readFileSync( - join(home, ".hermes", "config.yaml"), - "utf8", - ); + const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8"); expect(yamlText).toContain("default: qwen3-coder-plus"); expect(yamlText).toContain("provider: custom"); - expect(yamlText).toContain( - "base_url: https://dashscope.aliyuncs.com/compatible-mode/v1", - ); + expect(yamlText).toContain("base_url: https://dashscope.aliyuncs.com/compatible-mode/v1"); expect(yamlText).toContain("api_key: sk-hermes-placeholder"); // OpenAI 兼容端点不写 api_mode expect(yamlText).not.toContain("api_mode");