Skip to content

Commit 3ac3bba

Browse files
committed
feat(config-agent): add --restore to roll back agent configs from latest backup
1 parent afa43a4 commit 3ac3bba

10 files changed

Lines changed: 501 additions & 87 deletions

File tree

packages/commands/src/commands/config/agent/index.ts

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import { platform } from "os";
2-
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
2+
import {
3+
defineCommand,
4+
detectOutputFormat,
5+
maskToken,
6+
BailianError,
7+
ExitCode,
8+
type FlagsDef,
9+
} from "bailian-cli-core";
310
import { emitResult, emitBare } from "bailian-cli-runtime";
411
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
512
import { decodeTokenPlanKey } from "./decode-key.ts";
6-
import { resolveRegionBaseUrl } from "./writers/utils.ts";
13+
import {
14+
resolveRegionBaseUrl,
15+
findLatestBackup,
16+
restoreLatestBackup,
17+
} from "./writers/utils.ts";
718

819
const FLAGS = {
920
agent: {
@@ -39,12 +50,12 @@ const FLAGS = {
3950
type: "string",
4051
valueHint: "<model>",
4152
description: "Default model name",
42-
required: true,
4353
},
4454
contextWindow: {
4555
type: "number",
4656
valueHint: "<tokens>",
47-
description: "OpenClaw only: model context window in tokens (default: 256000)",
57+
description:
58+
"OpenClaw only: model context window in tokens (default: 256000)",
4859
},
4960
wireApi: {
5061
type: "string",
@@ -53,38 +64,106 @@ const FLAGS = {
5364
'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0',
5465
choices: ["chat", "responses"],
5566
},
67+
restore: {
68+
type: "switch",
69+
description:
70+
"Restore the agent's config files from the latest .bak backup created by this command",
71+
},
5672
} satisfies FlagsDef;
5773

5874
export default defineCommand({
5975
description: "Configure a coding agent to use DashScope API",
6076
auth: "none",
6177
usageArgs:
62-
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
78+
"--agent <name> ((--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model> | --restore)",
6379
flags: FLAGS,
6480
exampleArgs: [
6581
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
6682
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
6783
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
84+
"--agent claude-code --restore",
6885
],
6986
validate(flags) {
70-
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
71-
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
72-
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
73-
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
87+
if (flags.restore) {
88+
const writeFlags = [
89+
flags.baseUrl,
90+
flags.region,
91+
flags.apiKey,
92+
flags.key,
93+
flags.model,
94+
];
95+
if (writeFlags.some((value) => value !== undefined)) {
96+
return "--restore cannot be combined with --base-url/--region/--api-key/--key/--model";
97+
}
98+
return undefined;
99+
}
100+
if (!flags.model) return "--model is required";
101+
if (!flags.baseUrl && !flags.region)
102+
return "one of --base-url or --region is required";
103+
if (flags.baseUrl && flags.region)
104+
return "--base-url and --region are mutually exclusive";
105+
if (!flags.apiKey && !flags.key)
106+
return "one of --api-key or --key is required";
107+
if (flags.apiKey && flags.key)
108+
return "--api-key and --key are mutually exclusive";
74109
return undefined;
75110
},
76111
async run(ctx) {
77112
const { settings, flags } = ctx;
78113
const agentName = flags.agent;
79-
const { model, contextWindow, wireApi } = flags;
114+
const { contextWindow, wireApi } = flags;
115+
const agentDef = AGENTS[agentName];
116+
const format = detectOutputFormat(settings.output);
117+
118+
// --restore: roll each managed config file back to its newest .bak sibling.
119+
if (flags.restore) {
120+
const paths = agentDef.configPaths();
121+
122+
if (settings.dryRun) {
123+
emitResult(
124+
{
125+
agent: agentName,
126+
label: agentDef.label,
127+
restore: paths.map((path) => ({
128+
path,
129+
backup: findLatestBackup(path) ?? null,
130+
})),
131+
},
132+
format,
133+
);
134+
return;
135+
}
136+
137+
const results = paths.map((path) => restoreLatestBackup(path));
138+
if (results.every((result) => !result.backupPath)) {
139+
throw new BailianError(
140+
`No backups found for ${agentDef.label}.`,
141+
ExitCode.GENERAL,
142+
"Backups are created as <config>.bak.<timestamp> next to each config file when this command writes it.",
143+
);
144+
}
145+
146+
if (!settings.quiet) {
147+
emitBare(`${agentDef.label} config restored from backup.`);
148+
for (const result of results) {
149+
if (result.backupPath)
150+
emitBare(` Restored: ${result.path} <- ${result.backupPath}`);
151+
else emitBare(` Skipped (no backup): ${result.path}`);
152+
}
153+
}
154+
return;
155+
}
156+
157+
// Write mode — validate() guarantees these flags are present.
158+
const model = flags.model!;
80159
// --region is a Token Plan convenience: convert it into a base URL and use
81160
// it exactly as --base-url would be.
82-
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
161+
const baseUrl = flags.region
162+
? resolveRegionBaseUrl(flags.region)
163+
: flags.baseUrl!;
83164
// --key carries the web console's obfuscated form; decode it up front so
84165
// even --dry-run validates the token.
85166
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
86-
const agentDef = AGENTS[agentName];
87-
const format = detectOutputFormat(settings.output);
88167

89168
// Hermes has no native Windows support.
90169
if (agentName === "hermes" && platform() === "win32") {

packages/commands/src/commands/config/agent/writers/claude-code.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,28 @@ import {
99
} from "./utils.ts";
1010

1111
/** Fill a tier/default model env only when the user has not set it yet. */
12-
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
12+
function setModelEnvIfAbsent(
13+
env: Record<string, string>,
14+
key: string,
15+
model: string,
16+
): void {
1317
const current = env[key];
1418
if (current === undefined || current.trim() === "") {
1519
env[key] = model;
1620
}
1721
}
1822

23+
function configPaths(): string[] {
24+
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
25+
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
26+
return [join(configDir, "settings.json"), join(homedir(), ".claude.json")];
27+
}
28+
1929
export default {
2030
label: "Claude Code",
31+
configPaths,
2132
write({ baseUrl, apiKey, model }) {
22-
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
23-
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
24-
const settingsPath = join(configDir, "settings.json");
25-
const onboardingPath = join(homedir(), ".claude.json");
33+
const [settingsPath, onboardingPath] = configPaths();
2634
const warnings: string[] = [];
2735

2836
const resolved = resolveClaudeCodeBaseUrl(baseUrl);

packages/commands/src/commands/config/agent/writers/codex.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,28 @@ import { homedir } from "os";
22
import { join } from "path";
33
import { existsSync, readFileSync } from "fs";
44
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5-
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
5+
import {
6+
backup,
7+
readJson,
8+
writeJsonAtomic,
9+
writeTextAtomic,
10+
type AgentDef,
11+
} from "./utils.ts";
612

713
const PROVIDER_KEY = "bailian-cli";
814

15+
function configPaths(): string[] {
16+
return [
17+
join(homedir(), ".codex", "config.toml"),
18+
join(homedir(), ".codex", "auth.json"),
19+
];
20+
}
21+
922
export default {
1023
label: "Codex",
24+
configPaths,
1125
write({ baseUrl, apiKey, model, wireApi: wireApiParam }) {
12-
const configPath = join(homedir(), ".codex", "config.toml");
26+
const [configPath, authPath] = configPaths();
1327
const warnings: string[] = [];
1428

1529
// config.toml — merge into existing config so unrelated settings
@@ -18,7 +32,10 @@ export default {
1832
let config: Record<string, unknown> = {};
1933
if (existsSync(configPath)) {
2034
try {
21-
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
35+
config = parseToml(readFileSync(configPath, "utf-8")) as Record<
36+
string,
37+
unknown
38+
>;
2239
} catch {
2340
config = {};
2441
}
@@ -57,7 +74,6 @@ export default {
5774
writeTextAtomic(configPath, stringifyToml(config) + "\n");
5875

5976
// auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset.
60-
const authPath = join(homedir(), ".codex", "auth.json");
6177
backup(authPath);
6278
const auth = readJson(authPath);
6379
auth.OPENAI_API_KEY = apiKey;

packages/commands/src/commands/config/agent/writers/hermes.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,30 @@ import { homedir } from "os";
22
import { join } from "path";
33
import { existsSync, readFileSync } from "fs";
44
import yaml from "yaml";
5-
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
5+
import {
6+
backup,
7+
writeTextAtomic,
8+
isAnthropicEndpoint,
9+
type AgentDef,
10+
} from "./utils.ts";
11+
12+
function configPaths(): string[] {
13+
return [join(homedir(), ".hermes", "config.yaml")];
14+
}
615

716
export default {
817
label: "Hermes Agent",
18+
configPaths,
919
write({ baseUrl, apiKey, model }) {
10-
const configPath = join(homedir(), ".hermes", "config.yaml");
20+
const [configPath] = configPaths();
1121

1222
backup(configPath);
1323

1424
let config: Record<string, unknown> = {};
1525
if (existsSync(configPath)) {
1626
try {
17-
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
27+
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
28+
{}) as Record<string, unknown>;
1829
} catch {
1930
config = {};
2031
}

packages/commands/src/commands/config/agent/writers/openclaw.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
11
import { homedir } from "os";
22
import { join } from "path";
3-
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
3+
import {
4+
backup,
5+
readJson,
6+
writeJsonAtomic,
7+
isAnthropicEndpoint,
8+
type AgentDef,
9+
} from "./utils.ts";
410

511
// Safe default when --context-window is not given: most Model Studio models
612
// offer ≥256K context; users can raise it per model via the flag.
713
const DEFAULT_CONTEXT_WINDOW = 256000;
814

915
const PROVIDER_ID = "bailian-cli";
1016

17+
function configPaths(): string[] {
18+
return [join(homedir(), ".openclaw", "openclaw.json")];
19+
}
20+
1121
function readPrimary(defaults: Record<string, unknown>): string | undefined {
1222
const model = defaults.model;
1323
if (!model || typeof model !== "object") return undefined;
1424
const primary = (model as Record<string, unknown>).primary;
15-
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
25+
return typeof primary === "string" && primary.trim() !== ""
26+
? primary.trim()
27+
: undefined;
1628
}
1729

1830
export default {
1931
label: "OpenClaw",
32+
configPaths,
2033
write({ baseUrl, apiKey, model, contextWindow }) {
21-
const configPath = join(homedir(), ".openclaw", "openclaw.json");
34+
const [configPath] = configPaths();
2235
const warnings: string[] = [];
2336
const modelRef = `${PROVIDER_ID}/${model}`;
2437

@@ -30,7 +43,9 @@ export default {
3043
const models = (config.models ?? {}) as Record<string, unknown>;
3144
models.mode = "merge";
3245
const providers = (models.providers ?? {}) as Record<string, unknown>;
33-
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
46+
const api = isAnthropicEndpoint(baseUrl)
47+
? "anthropic-messages"
48+
: "openai-completions";
3449
providers[PROVIDER_ID] = {
3550
baseUrl,
3651
apiKey,

packages/commands/src/commands/config/agent/writers/opencode.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import { homedir } from "os";
22
import { join } from "path";
3-
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
3+
import {
4+
backup,
5+
readJsonc,
6+
writeJsonAtomic,
7+
isAnthropicEndpoint,
8+
type AgentDef,
9+
} from "./utils.ts";
10+
11+
function configPaths(): string[] {
12+
return [join(homedir(), ".config", "opencode", "opencode.json")];
13+
}
414

515
export default {
616
label: "OpenCode",
17+
configPaths,
718
write({ baseUrl, apiKey, model }) {
8-
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
19+
const [configPath] = configPaths();
920

1021
// opencode.json is JSONC — tolerate comments and trailing commas on read.
1122
backup(configPath);
@@ -14,7 +25,9 @@ export default {
1425
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
1526

1627
const provider = (config.provider ?? {}) as Record<string, unknown>;
17-
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
28+
const npm = isAnthropicEndpoint(baseUrl)
29+
? "@ai-sdk/anthropic"
30+
: "@ai-sdk/openai-compatible";
1831
provider["bailian-cli"] = {
1932
npm,
2033
name: "Alibaba Cloud Model Studio",

0 commit comments

Comments
 (0)