diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index 54bb55c..18ae02d 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -1,9 +1,16 @@ 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,7 +46,6 @@ const FLAGS = { type: "string", valueHint: "", description: "Default model name", - required: true, }, contextWindow: { type: "number", @@ -53,20 +59,34 @@ 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.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"; @@ -76,15 +96,56 @@ export default defineCommand({ 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!; // --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..f662192 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -16,13 +16,17 @@ function setModelEnvIfAbsent(env: Record, key: string, model: st } } +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..c92081d 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -6,10 +6,15 @@ import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } fro 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 @@ -57,7 +62,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..a5a5cca 100644 --- a/packages/commands/src/commands/config/agent/writers/hermes.ts +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -4,10 +4,15 @@ import { existsSync, readFileSync } from "fs"; import yaml from "yaml"; 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); diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 57d44c3..c1b7236 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -8,6 +8,10 @@ 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; @@ -17,8 +21,9 @@ function readPrimary(defaults: Record): string | 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}`; diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts index cc64b49..dc6c706 100644 --- a/packages/commands/src/commands/config/agent/writers/opencode.ts +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -2,10 +2,15 @@ import { homedir } from "os"; import { join } from "path"; 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); 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..9f5e0f4 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -8,6 +8,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 +39,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[] = []; diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index 32f3d04..bb8162d 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; } @@ -147,6 +157,41 @@ 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"); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index e9b1bdb..95ca4da 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,4 +1,4 @@ -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"; @@ -512,6 +512,99 @@ 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", 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 |