Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 68 additions & 7 deletions packages/commands/src/commands/config/agent/index.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -39,7 +46,6 @@ const FLAGS = {
type: "string",
valueHint: "<model>",
description: "Default model name",
required: true,
},
contextWindow: {
type: "number",
Expand All @@ -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 <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
"--agent <name> ((--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <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";
Expand All @@ -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 <config>.bak.<timestamp> 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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ function setModelEnvIfAbsent(env: Record<string, string>, 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);
Expand Down
8 changes: 6 additions & 2 deletions packages/commands/src/commands/config/agent/writers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string | undefined {
const model = defaults.model;
if (!model || typeof model !== "object") return undefined;
Expand All @@ -17,8 +21,9 @@ function readPrimary(defaults: Record<string, unknown>): 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}`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): boolean {
if (entry.envKey === ENV_KEY) return true;
Expand Down Expand Up @@ -35,8 +39,9 @@ function isBailianCliEntry(entry: Record<string, unknown>): 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[] = [];

Expand Down
49 changes: 47 additions & 2 deletions packages/commands/src/commands/config/agent/writers/utils.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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;
}

Expand Down Expand Up @@ -147,6 +157,41 @@ export function backup(path: string): void {
copyFileSync(path, `${path}.bak.${timestamp}`);
}

/** Locate the newest `.bak.<epoch>` 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.<epoch>` 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");
Expand Down
Loading