diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index ad4a4f1..6bac7d4 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -19,6 +19,7 @@ import { configList, configUse, configUi, + configAgent, update, appCall, appList, @@ -115,6 +116,7 @@ export const commands: Record = { "config list": configList, "config use": configUse, "config ui": configUi, + "config agent": configAgent, update, "app call": appCall, "app list": appList, diff --git a/packages/commands/package.json b/packages/commands/package.json index 974bd2b..d8e90a1 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -44,6 +44,7 @@ "bailian-cli-runtime": "workspace:*", "boxen": "catalog:", "chalk": "catalog:", + "smol-toml": "catalog:", "yaml": "catalog:" }, "devDependencies": { diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts new file mode 100644 index 0000000..b83e11a --- /dev/null +++ b/packages/commands/src/commands/config/agent/index.ts @@ -0,0 +1,71 @@ +import { platform } from "os"; +import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts"; + +const FLAGS = { + agent: { + type: "string", + valueHint: "", + description: `Target agent: ${VALID_AGENT_NAMES.join(", ")}`, + required: true, + choices: VALID_AGENT_NAMES, + }, + baseUrl: { type: "string", valueHint: "", description: "API base URL", required: true }, + apiKey: { type: "string", valueHint: "", description: "API key", required: true }, + model: { + type: "string", + valueHint: "", + description: "Default model name", + required: true, + }, +} satisfies FlagsDef; + +export default defineCommand({ + description: "Configure a coding agent to use DashScope API", + auth: "none", + usageArgs: "--agent --base-url --api-key --model ", + 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", + ], + async run(ctx) { + const { settings, flags } = ctx; + const agentName = flags.agent; + const { baseUrl, apiKey, model } = flags; + const agentDef = AGENTS[agentName]; + const format = detectOutputFormat(settings.output); + + // Hermes has no native Windows support. + if (agentName === "hermes" && platform() === "win32") { + process.stderr.write( + "Warning: Hermes Agent does not support native Windows. Please use WSL2.\n", + ); + } + + if (settings.dryRun) { + emitResult( + { + agent: agentName, + label: agentDef.label, + base_url: baseUrl, + api_key: maskToken(apiKey), + model, + }, + format, + ); + return; + } + + const params: WriteParams = { baseUrl, apiKey, model }; + const summary = agentDef.write(params); + + if (!settings.quiet) { + emitBare(`${agentDef.label} configured successfully.`); + for (const path of summary.paths) emitBare(` Written: ${path}`); + emitBare(` ${summary.nextStep}`); + } + }, +}); diff --git a/packages/commands/src/commands/config/agent/writers.ts b/packages/commands/src/commands/config/agent/writers.ts new file mode 100644 index 0000000..68aa95a --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers.ts @@ -0,0 +1,20 @@ +export type { WriteParams, WriteSummary, AgentDef } from "./writers/utils.ts"; + +import type { AgentDef } from "./writers/utils.ts"; +import claudeCode from "./writers/claude-code.ts"; +import qwenCode from "./writers/qwen-code.ts"; +import opencode from "./writers/opencode.ts"; +import openclaw from "./writers/openclaw.ts"; +import hermes from "./writers/hermes.ts"; +import codex from "./writers/codex.ts"; + +export const AGENTS: Record = { + "claude-code": claudeCode, + "qwen-code": qwenCode, + opencode, + openclaw, + hermes, + codex, +}; + +export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]]; diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts new file mode 100644 index 0000000..938f84b --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -0,0 +1,37 @@ +import { homedir } from "os"; +import { join } from "path"; +import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts"; + +export default { + label: "Claude Code", + write({ baseUrl, apiKey, model }) { + const settingsPath = join(homedir(), ".claude", "settings.json"); + const onboardingPath = join(homedir(), ".claude.json"); + + // settings.json — merge env. Base URL + auth token connect Claude Code to + // the endpoint; the model tier vars force every tier onto the chosen model. + backup(settingsPath); + const settings = readJson(settingsPath); + const env = (settings.env ?? {}) as Record; + env.ANTHROPIC_BASE_URL = baseUrl; + env.ANTHROPIC_AUTH_TOKEN = apiKey; + env.ANTHROPIC_MODEL = model; + env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model; + env.ANTHROPIC_DEFAULT_SONNET_MODEL = model; + env.ANTHROPIC_DEFAULT_OPUS_MODEL = model; + env.CLAUDE_CODE_SUBAGENT_MODEL = model; + settings.env = env; + writeJsonAtomic(settingsPath, settings); + + // .claude.json — skip the onboarding prompt on first launch. + backup(onboardingPath); + const onboarding = readJson(onboardingPath); + onboarding.hasCompletedOnboarding = true; + writeJsonAtomic(onboardingPath, onboarding); + + return { + paths: [settingsPath, onboardingPath], + nextStep: "Run `claude` to start using Claude Code with DashScope.", + }; + }, +} satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts new file mode 100644 index 0000000..5353d99 --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -0,0 +1,56 @@ +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"; + +const PROVIDER_KEY = "bailian-cli"; + +export default { + label: "Codex", + write({ baseUrl, apiKey, model }) { + const configPath = join(homedir(), ".codex", "config.toml"); + + // config.toml — merge into existing config so unrelated settings + // (mcp_servers, approval_policy, other providers, ...) are preserved. + backup(configPath); + let config: Record = {}; + if (existsSync(configPath)) { + try { + config = parseToml(readFileSync(configPath, "utf-8")) as Record; + } catch { + config = {}; + } + } + + config.model_provider = PROVIDER_KEY; + config.model = model; + config.model_reasoning_effort = "high"; + config.disable_response_storage = true; + + const providers = (config.model_providers ?? {}) as Record; + const existing = (providers[PROVIDER_KEY] ?? {}) as Record; + providers[PROVIDER_KEY] = { + ...existing, + name: PROVIDER_KEY, + base_url: baseUrl, + wire_api: "responses", + requires_openai_auth: true, + }; + config.model_providers = providers; + + writeTextAtomic(configPath, stringifyToml(config) + "\n"); + + // auth.json — Codex reads OPENAI_API_KEY from here. + const authPath = join(homedir(), ".codex", "auth.json"); + backup(authPath); + const auth = readJson(authPath); + auth.OPENAI_API_KEY = apiKey; + writeJsonAtomic(authPath, auth); + + return { + paths: [configPath, authPath], + nextStep: "Run `codex` to start using Codex with DashScope.", + }; + }, +} satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/hermes.ts b/packages/commands/src/commands/config/agent/writers/hermes.ts new file mode 100644 index 0000000..ae2de7a --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -0,0 +1,53 @@ +import { homedir } from "os"; +import { join } from "path"; +import { existsSync, readFileSync } from "fs"; +import yaml from "yaml"; +import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; + +const PROVIDER_NAME = "bailian-cli"; + +export default { + label: "Hermes Agent", + write({ baseUrl, apiKey, model }) { + const configPath = join(homedir(), ".hermes", "config.yaml"); + + backup(configPath); + + let config: Record = {}; + if (existsSync(configPath)) { + try { + config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record; + } catch { + config = {}; + } + } + + const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions"; + const providerEntry = { + name: PROVIDER_NAME, + base_url: baseUrl, + api_key: apiKey, + api_mode: apiMode, + models: [{ id: model, name: model }], + }; + + // custom_providers — upsert the bailian-cli entry by name. + const providers = Array.isArray(config.custom_providers) + ? (config.custom_providers as Array>) + : []; + const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME); + if (index >= 0) providers[index] = providerEntry; + else providers.push(providerEntry); + config.custom_providers = providers; + + // model — select the bailian-cli provider and default model. + config.model = { default: model, provider: PROVIDER_NAME }; + + writeTextAtomic(configPath, yaml.stringify(config)); + + return { + paths: [configPath], + nextStep: 'Run `hermes chat -q "hello"` to verify.', + }; + }, +} satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts new file mode 100644 index 0000000..71ec8c5 --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -0,0 +1,48 @@ +import { homedir } from "os"; +import { join } from "path"; +import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; + +export default { + label: "OpenClaw", + write({ baseUrl, apiKey, model }) { + const configPath = join(homedir(), ".openclaw", "openclaw.json"); + + backup(configPath); + const config = readJson(configPath); + + // models.providers["bailian-cli"] + const models = (config.models ?? {}) as Record; + models.mode = "merge"; + const providers = (models.providers ?? {}) as Record; + const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions"; + providers["bailian-cli"] = { + baseUrl, + apiKey, + api, + models: [ + { + id: model, + name: model, + contextWindow: 1000000, + cost: { input: 0, output: 0 }, + }, + ], + }; + models.providers = providers; + config.models = models; + + // agents.defaults + const agents = (config.agents ?? {}) as Record; + const defaults = (agents.defaults ?? {}) as Record; + defaults.model = { primary: `bailian-cli/${model}` }; + agents.defaults = defaults; + config.agents = agents; + + writeJsonAtomic(configPath, config); + + return { + paths: [configPath], + nextStep: "Run `openclaw` to start using OpenClaw with DashScope.", + }; + }, +} satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts new file mode 100644 index 0000000..87b729c --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -0,0 +1,32 @@ +import { homedir } from "os"; +import { join } from "path"; +import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; + +export default { + label: "OpenCode", + write({ baseUrl, apiKey, model }) { + const configPath = join(homedir(), ".config", "opencode", "opencode.json"); + + backup(configPath); + const config = readJson(configPath); + + 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"; + provider["bailian-cli"] = { + npm, + name: "Alibaba Cloud Model Studio", + options: { baseURL: baseUrl, apiKey, setCacheKey: true }, + models: { [model]: { name: model } }, + }; + config.provider = provider; + + writeJsonAtomic(configPath, config); + + return { + paths: [configPath], + nextStep: "Run `opencode` then type `/models` to select your model.", + }; + }, +} satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts new file mode 100644 index 0000000..437f19f --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -0,0 +1,61 @@ +import { homedir } from "os"; +import { join } from "path"; +import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; + +const ENV_KEY = "BAILIAN_CLI_API_KEY"; + +/** + * Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK + * protocol (an AuthType string), not by a free-form provider id — the runtime + * resolver indexes credentials/defaults by protocol. The `bailian-cli` brand + * therefore lives in the model entry `name` and the env var name. + */ +export default { + label: "Qwen Code", + write({ baseUrl, apiKey, model }) { + const settingsPath = join(homedir(), ".qwen", "settings.json"); + const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai"; + + backup(settingsPath); + const settings = readJson(settingsPath); + + // env — API key read by the provider entry's envKey. + const env = (settings.env ?? {}) as Record; + env[ENV_KEY] = apiKey; + settings.env = env; + + // modelProviders[] — upsert the bailian-cli model entry. + const providers = (settings.modelProviders ?? {}) as Record< + string, + Array> + >; + const entries = (providers[protocol] ?? []) as Array>; + const existing = entries.find( + (entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl, + ); + if (existing) { + existing.name = "bailian-cli"; + existing.baseUrl = baseUrl; + existing.envKey = ENV_KEY; + } else { + entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY }); + } + providers[protocol] = entries; + settings.modelProviders = providers; + + // security.auth — select the protocol and carry the OpenAI-compatible creds. + const security = (settings.security ?? {}) as Record; + security.auth = { selectedType: protocol, apiKey, baseUrl }; + settings.security = security; + + // model — active model, disambiguated by baseUrl. + settings.model = { name: model, baseUrl }; + + writeJsonAtomic(settingsPath, settings); + + return { + paths: [settingsPath], + nextStep: "Run `qwen` to start using Qwen Code with DashScope.", + }; + }, +} satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts new file mode 100644 index 0000000..bbc6a37 --- /dev/null +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -0,0 +1,59 @@ +import { dirname } from "path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs"; + +/** Parameters shared by every agent writer. */ +export interface WriteParams { + baseUrl: string; + apiKey: string; + model: string; +} + +/** What a writer reports back after configuring an agent. */ +export interface WriteSummary { + paths: string[]; + nextStep: string; +} + +/** An agent configuration writer: a human label plus a `write` that applies it. */ +export interface AgentDef { + label: string; + write(params: WriteParams): WriteSummary; +} + +/** Read a JSON object file, returning `{}` when missing or unparseable. */ +export function readJson(path: string): Record { + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")) as Record; + } catch { + return {}; + } +} + +/** Atomically write `data` as pretty JSON with owner-only permissions. */ +export function writeJsonAtomic(path: string, data: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = path + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); +} + +/** Atomically write raw text with owner-only permissions. */ +export function writeTextAtomic(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = path + ".tmp"; + writeFileSync(tmp, content, { mode: 0o600 }); + renameSync(tmp, path); +} + +/** Copy an existing file to a timestamped `.bak.` sibling. No-op if absent. */ +export function backup(path: string): void { + if (!existsSync(path)) return; + const timestamp = Math.floor(Date.now() / 1000); + copyFileSync(path, `${path}.bak.${timestamp}`); +} + +/** 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/src/index.ts b/packages/commands/src/index.ts index 63b31c4..08d8f02 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -22,6 +22,7 @@ export { default as configSet } from "./commands/config/set.ts"; export { default as configList } from "./commands/config/list.ts"; export { default as configUse } from "./commands/config/use.ts"; export { default as configUi } from "./commands/config/ui.ts"; +export { default as configAgent } from "./commands/config/agent/index.ts"; export { default as update } from "./commands/update.ts"; export { default as appCall } from "./commands/app/call.ts"; export { default as appList } from "./commands/app/list.ts"; diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts new file mode 100644 index 0000000..1537c20 --- /dev/null +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; +import { tmpdir, homedir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test"; +import claudeCode from "../src/commands/config/agent/writers/claude-code.ts"; +import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts"; +import opencode from "../src/commands/config/agent/writers/opencode.ts"; +import openclaw from "../src/commands/config/agent/writers/openclaw.ts"; +import hermes from "../src/commands/config/agent/writers/hermes.ts"; +import codex from "../src/commands/config/agent/writers/codex.ts"; +import yaml from "yaml"; + +/** + * Agent writer 单元测试:直接调用 writer,用临时 HOME 隔离文件系统。 + * writer 是纯文件 I/O,在进程内测试比 e2e 子进程更快、覆盖更全。 + */ + +const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"; +const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic"; + +let home = ""; +let prevHome: string | undefined; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "bl-agent-writer-")); + prevHome = process.env.HOME; + process.env.HOME = home; + // homedir() 在 POSIX 读 $HOME;断言隔离生效。 + expect(homedir()).toBe(home); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +function readJsonAt(...segments: string[]): Record { + return JSON.parse(readFileSync(join(home, ...segments), "utf8")); +} + +describe("config agent writers", () => { + test("claude-code 写入 env 与 onboarding,并合并已有 env", () => { + // 预置一个无关 env 键,验证合并保留 + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ env: { KEEP_ME: "1" }, other: true }), + ); + + const summary = claudeCode.write({ + baseUrl: ANTHROPIC_URL, + apiKey: "sk-a", + model: "qwen3-max", + }); + expect(summary.paths).toHaveLength(2); + + const settings = readJsonAt(".claude", "settings.json"); + const env = settings.env as Record; + expect(env.KEEP_ME).toBe("1"); + expect(settings.other).toBe(true); + expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a"); + expect(env.ANTHROPIC_MODEL).toBe("qwen3-max"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max"); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max"); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("qwen3-max"); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3-max"); + + expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true); + }); + + test("qwen-code compatible-mode 走 openai 协议", () => { + qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" }); + const settings = readJsonAt(".qwen", "settings.json"); + const security = settings.security as { auth: Record }; + expect(security.auth.selectedType).toBe("openai"); + expect(security.auth.apiKey).toBe("sk-q"); + expect(security.auth.baseUrl).toBe(OAI_URL); + expect((settings.env as Record).BAILIAN_CLI_API_KEY).toBe("sk-q"); + expect((settings.model as Record).name).toBe("qwen3-coder-plus"); + const providers = settings.modelProviders as Record>>; + expect(providers.openai[0]).toMatchObject({ + id: "qwen3-coder-plus", + name: "bailian-cli", + baseUrl: OAI_URL, + envKey: "BAILIAN_CLI_API_KEY", + }); + }); + + test("qwen-code anthropic 端点走 anthropic 协议", () => { + qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" }); + const settings = readJsonAt(".qwen", "settings.json"); + expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe( + "anthropic", + ); + const providers = settings.modelProviders as Record; + expect(Array.isArray(providers.anthropic)).toBe(true); + expect(providers.openai).toBeUndefined(); + }); + + test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => { + qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" }); + qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" }); + const settings = readJsonAt(".qwen", "settings.json"); + const openaiEntries = (settings.modelProviders as Record).openai; + expect(openaiEntries).toHaveLength(1); + }); + + test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => { + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + writeFileSync( + join(home, ".config", "opencode", "opencode.json"), + JSON.stringify({ provider: { other: { name: "Other" } } }), + ); + + opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" }); + const config = readJsonAt(".config", "opencode", "opencode.json"); + const provider = config.provider as Record>; + expect(provider.other).toBeDefined(); + expect(provider["bailian-cli"].npm).toBe("@ai-sdk/anthropic"); + const options = provider["bailian-cli"].options as Record; + expect(options.baseURL).toBe(ANTHROPIC_URL); + expect(options.apiKey).toBe("sk-o"); + expect(options.setCacheKey).toBe(true); + expect((provider["bailian-cli"].models as Record)["qwen3-max"]).toBeDefined(); + + // 非 anthropic 端点用 openai-compatible + opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" }); + expect( + ( + readJsonAt(".config", "opencode", "opencode.json").provider as Record< + string, + { npm: string } + > + )["bailian-cli"].npm, + ).toBe("@ai-sdk/openai-compatible"); + }); + + test("openclaw 写入 provider、api 与 primary", () => { + openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" }); + const config = readJsonAt(".openclaw", "openclaw.json"); + const models = config.models as Record; + expect(models.mode).toBe("merge"); + const bailian = (models.providers as Record>)["bailian-cli"]; + expect(bailian.api).toBe("openai-completions"); + expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus"); + const agents = config.agents as { defaults: { model: { primary: string } } }; + expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus"); + + // anthropic 端点用 anthropic-messages + openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" }); + const config2 = readJsonAt(".openclaw", "openclaw.json"); + expect( + ((config2.models as Record).providers as Record)[ + "bailian-cli" + ].api, + ).toBe("anthropic-messages"); + }); + + test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => { + mkdirSync(join(home, ".hermes"), { recursive: true }); + writeFileSync( + join(home, ".hermes", "config.yaml"), + yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }), + ); + + hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" }); + const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8")); + expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" }); + const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name); + expect(names).toContain("other"); + const entry = (config.custom_providers as Array>).find( + (provider) => provider.name === "bailian-cli", + )!; + expect(entry.base_url).toBe(OAI_URL); + expect(entry.api_key).toBe("sk-h"); + expect(entry.api_mode).toBe("chat_completions"); + + // anthropic 端点用 anthropic_messages + hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" }); + const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8")); + const entry2 = (config2.custom_providers as Array>).find( + (provider) => provider.name === "bailian-cli", + )!; + expect(entry2.api_mode).toBe("anthropic_messages"); + // upsert:bailian-cli 项不重复 + expect( + (config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"), + ).toHaveLength(1); + }); + + test("codex 写入 config.toml 与 auth.json(cc-switch 对齐结构,合并保留)", () => { + // 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并 + mkdirSync(join(home, ".codex"), { recursive: true }); + writeFileSync( + join(home, ".codex", "config.toml"), + [ + 'approval_policy = "on-request"', + "", + "[model_providers.other]", + 'name = "other"', + 'base_url = "https://other.example.com/v1"', + "", + ].join("\n"), + ); + // 预置 auth.json 无关键,验证合并保留 + writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" })); + + codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" }); + const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8"); + expect(toml).toContain('model_provider = "bailian-cli"'); + expect(toml).toContain('model = "qwen3-coder-plus"'); + expect(toml).toContain('model_reasoning_effort = "high"'); + expect(toml).toContain("disable_response_storage = true"); + expect(toml).toContain("[model_providers.bailian-cli]"); + expect(toml).toContain(`base_url = "${OAI_URL}"`); + expect(toml).toContain('wire_api = "responses"'); + expect(toml).toContain("requires_openai_auth = true"); + // 合并:保留用户已有的无关配置 + expect(toml).toContain('approval_policy = "on-request"'); + expect(toml).toContain("[model_providers.other]"); + + const auth = readJsonAt(".codex", "auth.json"); + expect(auth.OPENAI_API_KEY).toBe("sk-x"); + expect(auth.EXISTING).toBe("keep"); + }); + + test("已存在的配置文件会被备份为 .bak.", () => { + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 })); + + openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" }); + const backups = readdirSync(join(home, ".openclaw")).filter((name) => + name.startsWith("openclaw.json.bak."), + ); + expect(backups).toHaveLength(1); + }); +}); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 601db13..a28dcb6 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 } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; @@ -345,4 +345,141 @@ describe("e2e: config", () => { expect(exitCode).toBe(2); expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/); }); + + test("config agent --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/agent|--base-url|--model/i); + }); + + test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "agent", + "--agent", + "claude-code", + "--base-url", + "https://dashscope.aliyuncs.com/apps/anthropic", + "--model", + "qwen3-max", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--api-key|Usage:/i); + }); + + test("config agent 非法 --agent 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "agent", + "--agent", + "not-an-agent", + "--base-url", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "--api-key", + "sk-placeholder", + "--model", + "qwen3-max", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/not-an-agent|claude-code|agent/i); + }); + + test("config agent --dry-run 输出脱敏信息且不写盘", async () => { + const home = mkdtempSync(join(tmpdir(), "bl-config-agent-dry-")); + try { + const { stdout, stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "agent", + "--agent", + "claude-code", + "--base-url", + "https://dashscope.aliyuncs.com/apps/anthropic", + "--api-key", + "sk-secret-placeholder", + "--model", + "qwen3-max", + "--dry-run", + "--output", + "json", + ], + { HOME: home }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + agent?: string; + base_url?: string; + model?: string; + api_key?: string; + }>(stdout); + expect(data.agent).toBe("claude-code"); + 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); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => { + const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-")); + try { + const { stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "agent", + "--agent", + "codex", + "--base-url", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "--api-key", + "sk-codex-placeholder", + "--model", + "qwen3-coder-plus", + ], + { HOME: home }, + ); + expect(exitCode, stderr).toBe(0); + const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8"); + expect(toml).toContain('model_provider = "bailian-cli"'); + expect(toml).toContain("requires_openai_auth = true"); + expect(toml).toContain('wire_api = "responses"'); + const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8")); + expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("config agent hermes 写入 custom_providers 结构", async () => { + const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-")); + try { + const { stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + [ + "config", + "agent", + "--agent", + "hermes", + "--base-url", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "--api-key", + "sk-hermes-placeholder", + "--model", + "qwen3-coder-plus", + ], + { HOME: home }, + ); + expect(exitCode, stderr).toBe(0); + const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8"); + expect(yamlText).toContain("custom_providers"); + expect(yamlText).toContain("bailian-cli"); + expect(yamlText).toContain("api_mode: chat_completions"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); }); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 53cda0d..6f3e98a 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -18,6 +18,7 @@ export const CONFIG_ROUTES: E2eRouteExports = { "config list": "configList", "config use": "configUse", "config ui": "configUi", + "config agent": "configAgent", }; export const MEMORY_ROUTES: E2eRouteExports = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a434066..2c626c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: chalk: specifier: ^5.6.2 version: 5.6.2 + smol-toml: + specifier: ^1.4.2 + version: 1.7.0 tsx: specifier: ^4.23.0 version: 4.23.0 @@ -112,6 +115,9 @@ importers: chalk: specifier: 'catalog:' version: 5.6.2 + smol-toml: + specifier: 'catalog:' + version: 1.7.0 yaml: specifier: 'catalog:' version: 2.8.3 @@ -1269,6 +1275,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2166,6 +2176,8 @@ snapshots: sisteransi@1.0.5: {} + smol-toml@1.7.0: {} + source-map-js@1.2.1: {} std-env@4.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4f3e9e3..943a538 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: ajv: ^8.20.0 boxen: ^8.0.1 chalk: ^5.6.2 + smol-toml: ^1.4.2 tsx: ^4.23.0 typescript: ^5 undici: ^8.4.1 diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 5078fa7..32f6eda 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -7,16 +7,48 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ---------------- | ------------------------------------------------ | -| `bl config list` | List config profiles and show the active profile | -| `bl config set` | Set a config value | -| `bl config show` | Display current configuration | -| `bl config ui` | Open a local web UI to manage config profiles | -| `bl config use` | Set the active config profile | +| Command | Description | +| ----------------- | ------------------------------------------------ | +| `bl config agent` | Configure a coding agent to use DashScope API | +| `bl config list` | List config profiles and show the active profile | +| `bl config set` | Set a config value | +| `bl config show` | Display current configuration | +| `bl config ui` | Open a local web UI to manage config profiles | +| `bl config use` | Set the active config profile | ## Command details +### `bl config agent` + +| Field | Value | +| --------------- | --------------------------------------------------------------------------------- | +| **Name** | `config agent` | +| **Description** | Configure a coding agent to use DashScope API | +| **Usage** | `bl config agent --agent --base-url --api-key --model ` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------------------------------------------------------- | ------ | -------- | ----------------------------------------------------------------------- | +| `--agent ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | +| `--base-url ` | string | yes | API base URL | +| `--api-key ` | string | yes | API key | +| `--model ` | string | yes | Default model name | + +#### Examples + +```bash +bl config agent --agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max +``` + +```bash +bl config agent --agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus +``` + +```bash +bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus +``` + ### `bl config list` | Field | Value | diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 132c4a1..9601e16 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -17,6 +17,7 @@ Use this index for the full quick index and global flags. | `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) | | `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | | `bl auth status` | Show current authentication state | [auth.md](auth.md) | +| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) | | `bl config list` | List config profiles and show the active profile | [config.md](config.md) | | `bl config set` | Set a config value | [config.md](config.md) | | `bl config show` | Display current configuration | [config.md](config.md) | @@ -106,7 +107,7 @@ Use this index for the full quick index and global flags. | `advisor` | `recommend` | [advisor.md](advisor.md) | | `app` | `call`, `list` | [app.md](app.md) | | `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) | -| `config` | `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) | +| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) | | `console` | `call` | [console.md](console.md) | | `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | | `deploy` | `audio create`, `delete`, `get`, `image create`, `list`, `models`, `scale`, `text create`, `update` | [deploy.md](deploy.md) |