From d31b7f83cae508f779d00d8d2f7fae2e3c0ab62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Mon, 6 Jul 2026 15:59:33 +0800 Subject: [PATCH] refactor(core): split god Config into Identity/Settings/Credential resolved at the dispatch boundary - commands consume a narrowed context (identity/settings/own flags/client); config/auth commands additionally use configStore()/authStore() accessors - resolution happens once at dispatch: buildSources/buildSettings plus per-domain credential resolvers; dry-run tolerates missing credentials - transport takes structured deps; credentials are injected only by Client; console gateway takes a resolved target with optional token (anonymous catalog calls); pipeline steps and advisor run against client/settings - telemetry receives authMethod as a value; global/command flags are split at dispatch with a same-type shadowing guard at registry build - behavior change: base URL resolution now prefers DASHSCOPE_BASE_URL env over config file base_url (unified flag > env > file > default chain) - priority chains, store semantics and command capability boundaries are locked by unit tests --- docs/config-flags-refactor.md | 371 ++++++++++++++++++ .../src/commands/advisor/recommend.ts | 19 +- packages/commands/src/commands/app/call.ts | 14 +- packages/commands/src/commands/app/list.ts | 6 +- .../src/commands/auth/login-console.ts | 51 +-- packages/commands/src/commands/auth/login.ts | 21 +- packages/commands/src/commands/auth/logout.ts | 38 +- packages/commands/src/commands/auth/status.ts | 14 +- packages/commands/src/commands/config/set.ts | 20 +- packages/commands/src/commands/config/show.ts | 23 +- .../commands/src/commands/console/call.ts | 8 +- packages/commands/src/commands/file/upload.ts | 8 +- packages/commands/src/commands/image/edit.ts | 20 +- .../commands/src/commands/image/generate.ts | 48 +-- .../src/commands/knowledge/retrieve.ts | 8 +- packages/commands/src/commands/mcp/call.ts | 6 +- packages/commands/src/commands/mcp/list.ts | 10 +- packages/commands/src/commands/mcp/tools.ts | 6 +- packages/commands/src/commands/memory/add.ts | 12 +- .../commands/src/commands/memory/delete.ts | 8 +- packages/commands/src/commands/memory/list.ts | 8 +- .../src/commands/memory/profile-create.ts | 8 +- .../src/commands/memory/profile-get.ts | 8 +- .../commands/src/commands/memory/search.ts | 12 +- .../commands/src/commands/memory/update.ts | 8 +- packages/commands/src/commands/omni/chat.ts | 14 +- .../commands/src/commands/pipeline/run.ts | 14 +- .../src/commands/pipeline/validate.ts | 4 +- packages/commands/src/commands/quota/check.ts | 10 +- .../commands/src/commands/quota/history.ts | 10 +- packages/commands/src/commands/quota/list.ts | 8 +- .../commands/src/commands/quota/request.ts | 12 +- packages/commands/src/commands/search/web.ts | 14 +- .../commands/src/commands/speech/recognize.ts | 26 +- .../src/commands/speech/synthesize.ts | 38 +- packages/commands/src/commands/text/chat.ts | 18 +- packages/commands/src/commands/update.ts | 8 +- packages/commands/src/commands/usage/free.ts | 8 +- .../commands/src/commands/usage/freetier.ts | 6 +- packages/commands/src/commands/usage/stats.ts | 25 +- .../commands/src/commands/video/download.ts | 10 +- packages/commands/src/commands/video/edit.ts | 22 +- .../commands/src/commands/video/generate.ts | 30 +- packages/commands/src/commands/video/ref.ts | 22 +- .../commands/src/commands/video/task-get.ts | 8 +- .../commands/src/commands/vision/describe.ts | 6 +- .../commands/src/commands/workspace/list.ts | 8 +- packages/commands/tests/boundaries.test.ts | 32 ++ packages/core/src/advisor/cache.ts | 6 +- packages/core/src/advisor/embedding.ts | 23 +- packages/core/src/advisor/intent.ts | 11 +- packages/core/src/advisor/recall-semantic.ts | 8 +- packages/core/src/advisor/recommend.ts | 15 +- packages/core/src/advisor/sources/api.ts | 13 +- packages/core/src/auth/credentials.ts | 48 --- packages/core/src/auth/index.ts | 9 +- packages/core/src/auth/resolver.ts | 64 +-- packages/core/src/auth/store.ts | 64 +++ packages/core/src/client/client.ts | 50 ++- packages/core/src/client/http.ts | 38 +- packages/core/src/client/mcp.ts | 29 +- packages/core/src/config/index.ts | 6 +- packages/core/src/config/loader.ts | 60 +-- packages/core/src/config/schema.ts | 36 +- packages/core/src/config/store.ts | 38 ++ packages/core/src/console/gateway.ts | 52 ++- packages/core/src/telemetry/tracker.ts | 28 +- packages/core/src/types/command.ts | 29 +- packages/core/src/types/index.ts | 1 - packages/core/src/utils/output-dir.ts | 6 +- packages/core/tests/config-priority.test.ts | 165 ++++++++ packages/core/tests/config-store.test.ts | 66 ++++ packages/core/tests/index.test.ts | 44 ++- packages/runtime/src/create-cli.ts | 74 ++-- packages/runtime/src/error-handler.ts | 2 +- packages/runtime/src/middleware.ts | 87 ++-- packages/runtime/src/output/status-bar.ts | 16 +- packages/runtime/src/pipeline/bl-config.ts | 64 ++- packages/runtime/src/pipeline/executor.ts | 14 +- packages/runtime/src/pipeline/steps/bl-api.ts | 110 +++--- .../runtime/src/pipeline/steps/bl-steps.ts | 23 +- packages/runtime/src/pipeline/types.ts | 2 +- packages/runtime/src/registry.ts | 9 + packages/runtime/src/utils/concurrent.ts | 21 +- packages/runtime/src/utils/polling.ts | 13 +- 85 files changed, 1614 insertions(+), 838 deletions(-) create mode 100644 docs/config-flags-refactor.md create mode 100644 packages/commands/tests/boundaries.test.ts delete mode 100644 packages/core/src/auth/credentials.ts create mode 100644 packages/core/src/auth/store.ts create mode 100644 packages/core/src/config/store.ts create mode 100644 packages/core/tests/config-priority.test.ts create mode 100644 packages/core/tests/config-store.test.ts diff --git a/docs/config-flags-refactor.md b/docs/config-flags-refactor.md new file mode 100644 index 0000000..3f83405 --- /dev/null +++ b/docs/config-flags-refactor.md @@ -0,0 +1,371 @@ +# Config / Flags 解耦重构 — 实施方案(交接文档) + +> 目的:把当前的"god `Config`"拆成职责清晰的 `Identity / Settings / Credential`,并让命令只依赖收窄后的 `settings + flags + client`。本文是**自包含实施说明**,可据此直接开工。 +> +> 状态(2026-06-30):**尚未开始编码**,代码在基线。设计已充分对齐,并对照本地 clone 的 gh / vercel / oclif / citty / qwencloud 验证过。 +> +> 实施(2026-07-06):**已按本方案完成**——前置 baseUrl 翻转 + 阶段 0–6 全部落地(含 flags 收窄/分流/同名守卫、console/advisor/pipeline 收口、tracker 传值、边界守卫测试 `packages/commands/tests/boundaries.test.ts`)。全量 `vp check`/单测/关键 e2e 绿;**未 commit,待 review**。实施中的偏差:advisor 匿名调网关促使 `callConsoleGateway` 收 `ConsoleGatewayTarget`(token 可选)而非整个 credential;`describeAuth` 更名 `describeAuthState`;`ConfigStore.reset` 无消费者未实现。 +> +> 修订(2026-07-04 评审后,均已拍板):§8 改 strangler 分阶段 + 阶段 0 行为锁定测试(已落地);§2 store 接口细化(write async / unset / AuthStore.login 揽登录落盘);§5 validate 收 ownFlags + 同名守卫 + authStage dry-run 双域容忍;§7 tracker/workspaceId 修法;§0/§9 优先级链保真口径。dry-run 决策:**保持"无需凭证"现状**,console 三元组归 Settings 服务 dry-run 展示(不引入 ConsoleTarget);dry-run 输出规范统一推后(§9)。 +> +> 约束(务必遵守): +> +> - **不自动 commit**,改完等用户确认。 +> - **改 `packages/core` 或 `packages/runtime` 的 src 后必须 rebuild**:dev/工具走 `dist`,不 build 会跑旧代码。核心包重建:`pnpm -F bailian-cli-core build`。 +> - 每步用 `npx vp check`(= 类型检查 + 格式)兜底;基线是**绿的**(需先 `pnpm -F bailian-cli-core build` 刷新 dist,否则 `tools/generate-reference.ts` 从 dist 导入会报 stale 错)。 +> - 测试:`npx vp test`。 + +--- + +## 0. 一句话定位与不变式 + +> **ctx 是唯一组合根。它在边界处把 flag / env / file / 默认 各源解析成 `identity / settings / credential`,交给命令。** +> +> 优先级链已**统一为 flag > env > file > 默认**:唯一异类 baseUrl(原 flag>file>env)已在前置独立 commit 翻转,锁定表(`packages/core/tests/config-priority.test.ts`)同步更新,`buildSettings` 逐字段对照锁定表移植。workspaceId 无全局 flag 源(见 §9);verbose/noColor 为 OR 语义、telemetry 的 DO_NOT_TRACK 为业界标准,均非链序问题。 + +三条硬规矩: + +1. **业务命令只依赖 `settings / flags / client`**;`config`、`auth` 管理命令**额外**用 `configStore()` / `authStore()` 访问器。 +2. **只有边界(ctx 构造)知道优先级;只有 Client 持有 credential;raw `ConfigFile` 只在 `configStore` 后面。** +3. **命令的 `flags` 只含它自己声明的 flag**;全局 flag 进 `settings`,不进命令(实现见 §5 的分流规则)。 + +设计与 gh 的 `cmdutil.Factory` 模型同构(单一 context + 惰性能力访问器 + 边界收口解析);vercel `Client`、oclif `this.config` 亦然。已对照源码验证。 + +--- + +## 1. 划分判据(字段归属的唯一标准) + +> **非秘密的值:命令读它 → `Settings`;只有传输层(Client)用、命令不读 → 跟 `credential` 走、Client 内部消化。秘密(token)→ credential。** + +| 字段 | 命令读吗 | 归属 | +| ------------------------------------------------------ | ---------------------------- | ------------------------------------------ | +| `token` | —(秘密) | credential | +| `baseUrl` | 否(Client 拼 URL) | `ApiKeyCredential`(已有) | +| `region` / `site` / `switchAgent` | **是**(dry-run 分支展示) | **Settings + ConsoleCredential**(受控重叠) | +| `workspaceId` | **是**(塞请求 `data` + 校验) | **Settings** | +| `output` / `timeout` / `default*Model` / `verbose` / … | 是 | Settings | + +依据:`dry-run` 只 `emitResult({ request: body })` 不打印 URL(见 `commands/video/generate.ts`),所以 baseUrl 不必留在 settings;baseUrl 与 key 是 region 绑定的;workspaceId 的消费者全是 `auth:"console"` 命令,来自 console 登录回调,但**命令要读它的值**,故归 settings。console 三元组同理:`mcp/list`、`quota/check`、`console/call` 的 dry-run 分支要**展示** region/site(e2e 有断言),命令读它 → 归 Settings;真实调用由 `resolveConsole` 再解析进 credential(受控重叠,同一条 flag>file 链)。dry-run 各域输出不一致(model 域不打路由、console 域打)属 dry-run 规范问题,推后统一(§9),本次不为它引入新概念。 + +--- + +## 2. 目标类型 + +`packages/core/src/config/schema.ts`(`ConfigFile` 磁盘格式**不变**;新增 `Identity` / `Settings`,删除旧 `Config`): + +```ts +/** 静态产品身份,createCli 注入一次(bl/rag 各异,故注入,非模块常量)。*/ +export interface Identity { + binName: string; + version: string; + npmPackage: string; + clientName: string; // CliOptions 必填,无默认 +} + +/** 命令唯一会读的配置面(解析后的有效值)。不含身份/连接/作用域/秘密。*/ +export interface Settings { + configPath?: string; + output: "text" | "json"; + outputDir?: string; + timeout: number; + concurrent?: number; // 命令经 getConcurrency 读 → 归 settings + defaultTextModel?: string; + defaultVideoModel?: string; + defaultImageModel?: string; + defaultSpeechModel?: string; + defaultOmniModel?: string; + workspaceId?: string; // 命令读它 → 归 settings + consoleRegion?: string; // console 三元组:dry-run 展示要读 → 归 settings;真实调用另经 resolveConsole 进 credential + consoleSite?: "domestic" | "international"; + consoleSwitchAgent?: number; + verbose: boolean; + quiet: boolean; + noColor: boolean; + yes: boolean; + dryRun: boolean; + nonInteractive: boolean; // 0 消费者,可留可删;留着零风险 + async: boolean; + telemetry: boolean; +} +``` + +`packages/core/src/auth/types.ts`(**已经和方案一致,无需改**): + +```ts +export interface ApiKeyCredential { + token; + baseUrl; + source: "flag" | "env" | "config"; +} +export interface ConsoleCredential { + token; + region; + site: "domestic" | "international"; + switchAgent?; + source; +} +export interface AuthState { + apiKey?: ApiKeyCredential; + console?: ConsoleCredential; +} +``` + +新增管理能力接口(建议放 `config/` 与 `auth/`): + +```ts +export interface ConfigStore { + read(): ConfigFile; + write(patch: Partial): Promise; // writeConfigFile 本身 async + unset(keys: (keyof ConfigFile)[]): Promise; // 删 key 语义:Partial 表达不了 absent,单独给动词 + reset(): void; + path: string; +} +// login 揽下登录回调的全部落盘(access_token/base_url/console_*/workspace_id): +// 登录产生的写入属 auth 域职责,configStore 的 lint 边界不为 auth 命令放宽。 +export interface AuthStore { + describe(): AuthState; + login(opts): Promise; + logout(): void; +} +``` + +命令上下文 `packages/core/src/types/command.ts`(单一类型 + 惰性访问器): + +```ts +export interface CommandContext { + identity: Identity; + settings: Settings; + flags: ParsedFlags; // 只含本命令声明的 flag(不含全局) + client: Client; + configStore(): ConfigStore; // 惰性;lint 限定只在 commands/config/** 使用 + authStore(): AuthStore; // 惰性;lint 限定只在 commands/auth/** 使用 +} + +export interface Command { + description: string; + auth: AuthRequirement; // 凭证要求,不变 + flags?: F; + usageArgs?; + exampleArgs?; + notes?; + validate?: (flags: ParsedFlags) => string | undefined; // 收窄:只看命令自己的 flag + run: (ctx: CommandContext) => Promise; +} +export const defineCommand = (spec: Command) => spec; +``` + +运行时组合根(runtime 内部,中间件用;命令拿到的是窄视图 `CommandContext`): + +```ts +export interface RunContext extends CommandContext { + path: string[]; + command: AnyCommand; + sources: ResolutionSources; // 内部:providers / 访问器用;业务命令看不到(类型不暴露) +} +``` + +--- + +## 3. Client(结构化入参 + console 收口) + +`packages/core/src/client/client.ts`: + +```ts +class Client { + constructor(private deps: { + identity: Identity; + settings: Settings; // 只读 timeout / verbose + apiCred?: ApiKeyCredential; + consoleCred?: ConsoleCredential; + }) {} + + requestJson(opts): Promise // 用 apiCred.token + apiCred.baseUrl;UA 用 identity + request(opts): Promise + get baseUrl(): string { return this.deps.apiCred!.baseUrl; } // 删掉 ?? config.baseUrl 兜底 + + // console 域:收口 callConsoleGateway,内部从 consoleCred 注入 region/site/switchAgent/token。 + // dry-run 展示不走 client:命令读 settings.console* 经 effectiveConsoleGatewayConfig(见 §4)。 + callConsole({ api, data }): Promise + uploadFile(...); mcp(...); +} +``` + +- `http.ts` 的 `request(config, opts)` / `requestJson`:改为从 `identity` 取 `clientName`(UA)、从 `settings` 取 `timeout`/`verbose`。建议 Client 把它需要的窄参数传进去(userAgent/timeout/verbose),而不是整个对象。 +- `http.ts` 里 `!opts.noAuth` 分支现在会自己 `resolveApiKeyCredential(config)` —— 改为不再从 config 解析;Client 已注入 Authorization。梳理直接调用方(见 §7)。 + +--- + +## 4. 解析边界(单一源对象 + provider chain) + +`packages/core/src/config/loader.ts`(把 `loadConfig(flags)` 拆掉): + +```ts +// flags 收 Partial:ParsedFlags 里 switch 是必填 boolean,收 Partial 让 pipeline 传 {} 不必 as any +export interface ResolutionSources { + flags: Partial; + file: ConfigFile; + env: NodeJS.ProcessEnv; /* profile?: 未来 */ +} + +export function buildSources(globalFlags: GlobalFlags): ResolutionSources { + return { flags: globalFlags, file: readConfigFile(), env: process.env }; +} + +/** 纯解析:不含身份、不含 baseUrl/console*、不含鉴权源;保留 timeout 校验逻辑。*/ +export function buildSettings(s: ResolutionSources): Settings { + /* 各字段按既有链保真移植(链不统一,见 §9);锁定表 tests/config-priority.test.ts */ +} +``` + +`packages/core/src/auth/resolver.ts`(改吃 sources): + +```ts +const apiKeyChain = [fromFlag, fromEnv, fromFile]; // 顺序即优先级 +export function resolveApiKey(s: ResolutionSources): ApiKeyCredential; // { token, baseUrl: flags.baseUrl ?? file.base_url ?? env ?? REGIONS.cn, source } +export function resolveConsole(s: ResolutionSources): ConsoleCredential; // { token: file.access_token, region, site, switchAgent, source } +export function describeAuth(s: ResolutionSources): AuthState; // auth status 用 +``` + +`packages/core/src/console/gateway.ts`:`callConsoleGateway` 改为从 `Client.callConsole` 传入的 consoleCred 取 region/site/switchAgent;`effectiveConsoleGatewayConfig` 参数从 `Config` 收窄为 settings 的 console 三元组,继续服务 dry-run 分支的展示(默认值 cn-beijing/domestic 仍在此兜)。credential 与 settings 两份三元组走同一条 flag>file 链,解析共用同一内部小函数防漂移。 + +--- + +## 5. dispatch 流程(`packages/runtime/src/create-cli.ts`) + +```ts +case "run": { + // 1) 一次解析(全局+命令 flag 合并) + const parsed = parseFlags(res.rest, { ...GLOBAL_FLAGS, ...res.command.flags }); + + // 2) 分流(见下"分流规则"),validate 收收窄后的 ownFlags(与 §2 签名一致,别传 parsed) + const globals = pick(parsed, Object.keys(GLOBAL_FLAGS)); // 全局 flag → sources + const ownFlags = pick(parsed, Object.keys(res.command.flags ?? {})); // 命令声明的 → ctx.flags + const invalid = res.command.validate?.(ownFlags); + if (invalid) throw new UsageError(invalid); + + // 3) 建源一次 → settings + const sources = buildSources(globals); + const settings = buildSettings(sources); + + // 4) 组 ctx;惰性访问器;client 由 authStage 填 + const ctx: RunContext = { + identity, settings, flags: ownFlags, client: undefined, + configStore: () => makeConfigStore(sources), + authStore: () => makeAuthStore(sources), + path: res.path, command: res.command, sources, + }; + await runMiddleware(ctx); // authStage 用 sources 解析 credential、建 client + await res.command.run(ctx); // 统一一句,无分叉 + await flushTelemetry(1000); +} +``` + +**分流规则(重要,含"本次不改遮蔽"的妥协):** + +> **全局 flag 恒进 `sources`(用 `Object.keys(GLOBAL_FLAGS)`);命令声明的 flag 进 `ctx.flags`(用 `Object.keys(command.flags)`)。同名遮蔽者两边都出现(受控重叠)。** + +为什么这样:约 15 个命令把全局 flag(`consoleSite/consoleRegion/switchAgent`、`async`)重声明成自己的,纯为 help 显示(声明不读)。若"命令声明的 key 一律归 ctx.flags 且不进 sources",这些遮蔽会让 `--console-region` 到不了 `resolveConsole`,区域覆盖静默失效。**本次不清理这些遮蔽**(已记录在钉钉文档),用"全局恒进 sources"规避,行为不变;代价是这 ~15 个 flag 在 ctx.flags 和 sources 各出现一次(auth login 的 apiKey/baseUrl 也在两边,但 auth:none 不解析,无害)。 + +**同名守卫**:registry 构建时断言 —— 命令 flag 与全局同名时,其 FlagDef `type` 必须一致。分流规则依赖"遮蔽都是同型重声明"这一假设;十行断言把口头约定变成机器约定,防止未来有人把 `async` 重声明成 value flag 后,错型值静默流进 sources。 + +`authStage`(`packages/runtime/src/middleware.ts`): + +```ts +const authStage = async (ctx, next) => { + const base = { identity: ctx.identity, settings: ctx.settings }; + if (ctx.command.auth === "apiKey") + ctx.client = new Client({ ...base, apiCred: resolveApiKey(ctx.sources) }); + else if (ctx.command.auth === "console") + ctx.client = new Client({ ...base, consoleCred: resolveConsole(ctx.sources) }); + else ctx.client = new Client(base); + // dry-run:apiKey/console 两域 resolve 失败都不抛,保持"dry-run 无需凭证"现状 + // (console 的 dry-run 分支不走 client,展示读 settings.console*;真实执行仍 fail fast) + await next(); +}; +``` + +`identity` 来自 `CliOptions`(`createCli` 的入参),在 createCli 里构造一次:`{ binName, version, npmPackage, clientName }`。`CliOptions.clientName` 由可选改**必填**、删 `?? binName` 默认(create-cli.ts:65);bl 的 main.ts 本就显式传 `"bailian-cli"`,无调用方受影响。 + +--- + +## 6. 字段迁移对照(旧 `Config` → 去向) + +| 旧 `Config` 字段 | 去向 | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `clientName` / `clientVersion` | **Identity**(`clientName` / `version`) | +| `binName` / `npmPackage` | **Identity** | +| `apiKey` / `apiKeyEnv` / `fileApiKey` / `fileAccessToken` | **删除** → provider chain 从 sources 读 | +| `baseUrl` | **ApiKeyCredential.baseUrl**(resolveApiKey 里解析) | +| `consoleSite` / `consoleRegion` / `consoleSwitchAgent` | **Settings**(dry-run 展示)+ **ConsoleCredential**(真实调用),受控重叠 | +| `workspaceId` | **Settings** | +| `output` / `outputDir` / `timeout` / `default*Model` | **Settings** | +| `verbose` / `quiet` / `noColor` / `dryRun` / `async` / `yes` / `telemetry` / `configPath` | **Settings** | +| `concurrent`(原本仅 flags) | **Settings**(`getConcurrency` 改读 settings) | + +--- + +## 7. 关键消费点改动清单(编译器会逐一列出;这些是已知的) + +- `new Client(config, apiCred?, consoleCred?)` → `new Client({ identity, settings, apiCred?, consoleCred? })`(约 3 处) +- `client/http.ts` / `client/mcp.ts`:`config.clientName/clientVersion` → `identity.*`;`config.timeout` → `settings.timeout`;`config.verbose` → `settings.verbose` +- `telemetry/tracker.ts`:`clientVersion`(:133)→ `identity.version`;**authMethod(:122-126)现从 `config.apiKey/apiKeyEnv/fileApiKey/fileAccessToken` 推断,这四个字段将被删** —— 改为 telemetryStage 调 `describeAuth(ctx.sources)` 映射出 authMethod 后**传值**进 tracker(不传 store 句柄,遥测不该拿到 login/logout 能力);`extractParams` 改收 `ctx.flags`(它现在就过滤全局 flag + allowlist,产出逐字节不变,过滤全局那行可删) +- `client/client.ts:38`:`apiCred?.baseUrl ?? config.baseUrl` → `apiCred.baseUrl` +- 命令读 `config.binName`(约 6 处:`auth/status`、`usage/stats`、`mcp/list`、`quota/history`、`quota/request`)→ `identity.binName`(经 ctx) +- **console 收口**:约 12 处 `callConsoleGateway(config, token, {api,data})`(`app/list`、`workspace/list`、`usage/*`、`mcp/list`、`quota/*`、`console/call`)→ `ctx.client.callConsole({api,data})`;dry-run 里的 `effectiveConsoleGatewayConfig(config)` → `effectiveConsoleGatewayConfig(settings)`(签名收窄,不走 client) +- **workspaceId**:`usage/stats.ts` 的 `resolveWorkspaceId(config, flag)` → `flags.workspaceId ?? requireWorkspace(ctx.settings)`(新 helper 只兜 settings,缺失时报原来的错 + `${identity.binName} workspace list` 提示)。**注意:`--workspace-id` 是命令级 flag、不在 GLOBAL_FLAGS,进不了 sources/settings,flag 的第一优先级必须在命令里显式保住**,否则静默丢失 +- **config/auth 命令**:`readConfigFile/writeConfigFile/resolver` 直接调用 → 走 `ctx.configStore()` / `ctx.authStore()` +- **auth/login `validate`**:`!f.console && !f.apiKey`——`apiKey`/`baseUrl` 是它自己声明的 flag(`login.ts:15`),收窄后仍在 `ctx.flags`,**无需改** +- **pipeline**:`buildPipelineConfig`(伪造整套 GlobalFlags,`runtime/src/pipeline/bl-config.ts`)→ `buildSettings({ flags: {}, file: readConfigFile(), env })`(flags 已收 Partial,见 §4) + 强制 `output:'json'/quiet/nonInteractive`;pipeline executor 是"迷你边界",给 step 构造 settings/client +- 其余把 `Config` 当类型用的地方 → `Settings`;ctx 字段 `config` → `settings`(`ctx.config` 仅 2 处、解构 `const { config } = ctx` 约 46 处 + 其函数体内 `config.` → `settings.`) + +**命名注意**: + +- 类型 `Config` → `Settings`;但 `\bConfig\b` 也出现在**注释/字符串**里("Config saved to"、"Config key …"),**不能盲目 sed**,要按类型位置改。 +- `ConfigFile` / `readConfigFile` / `writeConfigFile` / `loadConfig`→`buildSettings` / `getConfigPath` / `configPath` 这些**不是** `Config` 类型,别误改。 +- ctx 字段用 `settings`;访问器 `configStore()` / `authStore()`(不用 `config`/`auth`,避免和 god-config、`command.auth` 撞名)。 + +--- + +## 8. 分阶段落地(strangler:只加不删、双轨过渡、最后删旧) + +> 原则:**每阶段结束 `npx vp check` 真绿**,可提交、可交接;破坏性签名变更与其全部调用点落在同一阶段;跨阶段共存的旧载体最后统一删。改 core 后 `pnpm -F bailian-cli-core build`。 + +0. **行为锁定测试(已完成)**:`packages/core/tests/config-priority.test.ts` 锁住各字段既有优先级链(11 用例,绿)——阶段 1 写 `buildSettings` 时把同一张表指向它,任何链被归一/写错立刻红。dry-run 那族已有 `packages/cli/tests/e2e/console-flags.e2e.test.ts` 覆盖(无登录环境即锁未登录路径);可顺手加 `BAILIAN_CONFIG_DIR` 隔离,保证在已登录的开发机上也走未登录路径。 +1. **核心类型与解析(只加不删)**:schema.ts 加 `Identity`/`Settings`(**保留**旧 `Config`);新 `CommandContext`/`Command` 形状、`ConfigStore`/`AuthStore` 接口;loader 加 `buildSources`/`buildSettings`(保留 `loadConfig`);resolver/gateway 加吃 `ResolutionSources` 的新函数(旧签名保留)。 +2. **边界切换(runtime 双轨)**:Client 换新构造 `{identity,settings,creds}` + `callConsole`/`describeConsoleTarget`,http/mcp/telemetry 改读 identity/settings —— 连同其**全部调用点**(dispatch、authStage、约 3 处 new Client)同阶段改完;dispatch 同时构造旧 `config` 与新 `settings`,`RunContext` 双挂(临时胶水),commands 仍读 `ctx.config` 不受影响。 +3. **commands 迁移**:`config.` → `settings.`/`identity.`、console 收口到 `ctx.client.callConsole`、workspaceId helper、config/auth 命令改访问器。可按命令域拆成多次,每次都绿。 +4. **pipeline**:executor 改迷你边界,删 bl-config 假 flags。 +5. **删旧**:删 `Config`/`loadConfig`/gateway 旧签名/`ctx.config` 及双轨胶水 —— 编译器把漏网之鱼全部列出,逐一清零。 +6. **lint 规则**(`configStore()`/`authStore()` 仅 `commands/config/**`、`commands/auth/**`)+ 全量 `npx vp check` + `npx vp test` 绿;`pnpm -F bailian-cli-core build`。**不 commit**,交用户确认。 + +--- + +## 9. 本次不做(已在钉钉文档记录,后续单独轮次) + +- **flag 清理**:`nonInteractive` 删 / `async` ↔ 各命令 `--no-wait` 去重 / `yes` 收窄到命令级 / `noColor` 修一致性(registry/progress/banner 里内联 `process.stderr.isTTY` 绕过了 `config.noColor`)。 +- **workspaceId 的 flag 源**:今天没有全局 `--workspace-id`,只有 `usage/stats` 自声明的命令级 flag(命令内做 flag > settings 覆盖)。是否提升为全局 flag,与下条同名遮蔽清理**同一轮**看——都是全局↔命令级 flag 的边界问题。(优先级链归一本身已完成:唯一异类 baseUrl 已前置翻转,见 §0。) +- **dry-run 输出规范统一**:各域输出现状不一致 —— model/app 域只打请求 body(不含 URL/baseUrl),console 域额外打 api 名 + region/site 路由信息。应一次定规范、跨域对齐(是否展示路由、展示哪些字段);届时若 console 域不再展示,console 三元组可从 Settings 撤出、收敛为纯 credential。**本次保持现状输出**(e2e 有断言)。 +- **全局↔命令私有 flag 同名遮蔽**清理(§5 提到的 ~15 个)。本次用"全局恒进 sources"规避,不动声明。 +- **key ↔ baseUrl 强校验**(region 锁)。落点已就位:`resolveApiKey` 是唯一同时产出 `{token, baseUrl}` 的地方,校验加在它内部即可;baseUrl 不在 Settings,命令侧无法绕过绑定;`AuthStore.login` 已支持 `api_key` + `base_url` 成对落盘。 +- **多 profile / 多身份**(arkcli 式)。结构已留缝:单一 `ResolutionSources` 边界 + credential 封装。 +- **IOStreams 注入**(gh `Factory.IOStreams` / vercel `Client.stdout`);与 noColor 修复同属下一轮。 +- `ConfigFile`(磁盘格式)不变;无用户可见 CLI 变化。 + +外部记录:钉钉文档 `https://alidocs.dingtalk.com/i/nodes/YMyQA2dXW7gYo6MzcZzzERNMWzlwrZgb`(全局 flag 对比、flag 清理项、遮蔽问题)。 + +--- + +## 10. 为什么这套一次到位、可逆不返工 + +- **单一 CommandContext + 惰性访问器**:idiomatic(gh `f.Config()`/`f.Config().Authentication()`、vercel `client.config`/`authConfig`、oclif `this.config`),无 `kind`、无多工厂、无分叉。 +- **flag 收窄**:`ctx.flags` 只含命令 flag,与 `settings` 零重叠(遮蔽的 ~15 个是本次已知的受控例外)。 +- **credential 扁平但封在 Client**:将来要结构化(secret/connection/scope 分层)是 Client 内局部重构,类型兜底、磁盘格式不变——故现在 YAGNI。 +- **单一 `sources` 边界**:未来 profile 加一维,dispatch/ctx 形状不变。 +- **provider chain**:加鉴权源 = 加一个 provider。 + +## 11. 参考实现(本地 clone,`../cli架构/`) + +- **gh**(`github-cli/`):`cmdutil.Factory` = 单一 context;全局 flag 经 `PersistentPreRunE` 注入访问器(`repo_override.go`);config set 用 `f.Config().Set/Write`(`cmd/config/set/set.go`);auth login 用 `f.Config().Authentication().Login()`(`cmd/auth/login/login.go`)。**最贴合本方案。** +- **vercel**(`vercel/packages/cli/`):单一 `Client` 注入每个命令;`config`(GlobalConfig)与 `authConfig` 分离;token 优先级链(flag>env>file)。 +- **oclif**(`oclif-core/`):`this.config`(静态+持久);`baseFlags`+`flags` 在 `parse()` 合并(注意:oclif 默认命令能看到全局 flag,我们比它更严——收窄)。 +- **qwencloud** / **citty**:身份/元数据与 flags 分离;citty 薄 context,依赖注入留给使用者。 diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index 29b6eb2..0599329 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -231,19 +231,18 @@ export default defineCommand({ '--message "Long document summarization" --dry-run', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const userInput = flags.message; - const top = 3; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const modelsOptions: GetModelsOptions = { onPrepareStart: () => process.stderr.write("Initializing model data...\n"), }; process.stderr.write("Analyzing your request...\n"); const [allModels, intent] = await Promise.all([ - getModels(config, modelsOptions), - analyzeIntent(config, userInput), + getModels(settings, modelsOptions), + analyzeIntent(ctx.client, userInput), ]); if (intent.confidence === 0) { @@ -253,9 +252,9 @@ export default defineCommand({ } // Stage 2: Candidate Recall (semantic recall, auto-builds embeddings on first run) - const candidates = await recallSemantic(config, allModels, userInput, 50, intent); + const candidates = await recallSemantic(ctx.client, allModels, userInput, 50, intent); - if (config.dryRun) { + if (settings.dryRun) { emitResult( { userInput, @@ -276,7 +275,7 @@ export default defineCommand({ const spinner = createSpinner("Recommending best models..."); spinner.start(); - const result = await rankModels(config, candidates, intent, userInput, top); + const result = await rankModels(ctx.client, candidates, intent, userInput, top); spinner.stop(); @@ -309,8 +308,8 @@ export default defineCommand({ return; } - emitBare(formatIntentSummary(intent, config.noColor)); + emitBare(formatIntentSummary(intent, settings.noColor)); emitBare(""); - emitBare(formatResult(result, config.noColor)); + emitBare(formatResult(result, settings.noColor)); }, }); diff --git a/packages/commands/src/commands/app/call.ts b/packages/commands/src/commands/app/call.ts index ecc83c3..aa8960b 100644 --- a/packages/commands/src/commands/app/call.ts +++ b/packages/commands/src/commands/app/call.ts @@ -65,12 +65,12 @@ export default defineCommand({ '--app-id abc123 --prompt "Start" --biz-params \'{"key":"value"}\'', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const appId = flags.appId; const prompt = flags.prompt; const shouldStream = flags.stream || process.stdout.isTTY; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const body: AppCompletionRequest = { input: { prompt }, @@ -119,7 +119,7 @@ export default defineCommand({ } } - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(appCompletionPath(appId)), request: body }, format); return; } @@ -137,8 +137,8 @@ export default defineCommand({ let fullText = ""; let sessionId = ""; const writesStreamingStdout = format === "text"; - const dim = config.noColor ? "" : "\x1b[2m"; - const reset = config.noColor ? "" : "\x1b[0m"; + const dim = settings.noColor ? "" : "\x1b[2m"; + const reset = settings.noColor ? "" : "\x1b[0m"; for await (const event of parseSSE(res)) { if (event.data === "[DONE]") break; @@ -175,7 +175,7 @@ export default defineCommand({ } // Show session_id for multi-turn conversation - if (sessionId && !config.quiet) { + if (sessionId && !settings.quiet) { process.stderr.write(`${dim}Session ID: ${sessionId}${reset}\n`); } @@ -193,7 +193,7 @@ export default defineCommand({ const text = response.output?.text ?? ""; - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { emitBare(text); } else { emitResult(response, format); diff --git a/packages/commands/src/commands/app/list.ts b/packages/commands/src/commands/app/list.ts index 66745a5..5205ad3 100644 --- a/packages/commands/src/commands/app/list.ts +++ b/packages/commands/src/commands/app/list.ts @@ -33,11 +33,11 @@ export default defineCommand({ }, exampleArgs: ["", "--name customer service", "--page 2 --page-size 10", "--output json"], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const name = flags.name || ""; const pageNo = flags.page || 1; const pageSize = flags.pageSize || 30; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const data = { reqDTO: { @@ -50,7 +50,7 @@ export default defineCommand({ }, }; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ api: APP_LIST_API, data }, format); return; } diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index eb121f8..2b26e9e 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -7,12 +7,20 @@ import { ExitCode, chatPath, getConfigPath, - readConfigFile, requestJson, - writeConfigFile, - type Config, + type AuthStore, + type ConfigFile, + type Identity, + type Settings, } from "bailian-cli-core"; +/** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */ +export interface LoginDeps { + identity: Identity; + settings: Settings; + authStore: AuthStore; +} + const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; const MAX_AUTH_CALLBACK_BODY = 65536; @@ -399,16 +407,17 @@ function canRetry(err: unknown): boolean { } export async function validateAndPersistApiKey( - config: Config, + deps: LoginDeps, key: string, baseUrl: string, ): Promise { process.stderr.write("Testing key... "); - const testConfig = { ...config, apiKey: key, baseUrl }; + const httpDeps = { identity: deps.identity, settings: deps.settings }; const requestOpts = { - url: testConfig.baseUrl + chatPath(), + url: baseUrl + chatPath(), method: "POST", - timeout: Math.min(config.timeout, 30), + headers: { Authorization: `Bearer ${key}` }, + timeout: Math.min(deps.settings.timeout, 30), body: { model: "qwen3.7-max", messages: [{ role: "user", content: "hi" }], @@ -418,7 +427,7 @@ export async function validateAndPersistApiKey( for (let attempt = 1; attempt <= 3; attempt++) { try { - await requestJson(testConfig, requestOpts); + await requestJson(httpDeps, requestOpts); break; } catch (err) { if (attempt >= 3 || !canRetry(err)) { @@ -433,14 +442,12 @@ export async function validateAndPersistApiKey( } process.stderr.write("Valid\n"); - const existing = readConfigFile() as Record; - existing.api_key = key; - await writeConfigFile(existing); + await deps.authStore.login({ api_key: key }); } export async function runConsoleLogin( consoleOrigin: string, - config: Config, + deps: LoginDeps, opts?: { needApiKey?: boolean }, ): Promise { const state = randomBytes(16).toString("hex"); @@ -480,19 +487,19 @@ export async function runConsoleLogin( if (hasConfig || apiKey) { try { if (hasConfig) { - const existing = readConfigFile() as Record; - if (accessToken) existing.access_token = accessToken; - if (baseUrl) existing.base_url = baseUrl; - if (consoleSite) existing.console_site = consoleSite; - if (consoleRegion) existing.console_region = consoleRegion; - if (consoleSwitchAgent) existing.console_switch_agent = Number(consoleSwitchAgent); - if (workspaceId) existing.workspace_id = workspaceId; - await writeConfigFile(existing); + await deps.authStore.login({ + access_token: accessToken || undefined, + base_url: baseUrl || undefined, + console_site: (consoleSite || undefined) as ConfigFile["console_site"], + console_region: consoleRegion || undefined, + console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined, + workspace_id: workspaceId || undefined, + }); process.stderr.write(`Config saved to ${getConfigPath()}\n`); } if (apiKey) { - const testBaseUrl = baseUrl || config.baseUrl; - await validateAndPersistApiKey(config, apiKey, testBaseUrl); + const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl(); + await validateAndPersistApiKey(deps, apiKey, testBaseUrl); } } catch (err: unknown) { callbackError = err; diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index a0d4381..6ef4755 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,4 +1,4 @@ -import { defineCommand, readConfigFile, writeConfigFile } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { printQuickStart } from "bailian-cli-runtime"; import { emitBare } from "bailian-cli-runtime"; import { @@ -27,16 +27,18 @@ export default defineCommand({ exampleArgs: ["--api-key sk-xxxxx", "--console"], validate: (f) => (!f.console && !f.apiKey ? "Provide --api-key or --console" : undefined), async run(ctx) { - const { config, flags } = ctx; + const { identity, settings, flags } = ctx; + const store = ctx.authStore(); + const deps = { identity, settings, authStore: store }; if (flags.console) { - if (config.dryRun) { + if (settings.dryRun) { emitBare( "Would bind a free port on 127.0.0.1 and open the console login URL in your browser.", ); return; } - const hasApiKey = !!(config.apiKey || config.fileApiKey); - await runConsoleLogin(resolveConsoleOrigin(config.consoleSite || "domestic"), config, { + const hasApiKey = !!(flags.apiKey || store.stored().apiKey); + await runConsoleLogin(resolveConsoleOrigin(settings.consoleSite || "domestic"), deps, { needApiKey: !hasApiKey, }); return; @@ -46,18 +48,15 @@ export default defineCommand({ if (flags.apiKey) { const key = flags.apiKey; const baseUrl = flags.baseUrl || undefined; - const effectiveConfig = baseUrl ? { ...config, baseUrl } : config; - if (config.dryRun) { + if (settings.dryRun) { emitBare("Would validate and save API key."); return; } if (baseUrl) { - const existing = readConfigFile() as Record; - existing.base_url = baseUrl; - await writeConfigFile(existing); + await store.login({ base_url: baseUrl }); } - await validateAndPersistApiKey(effectiveConfig, key, effectiveConfig.baseUrl); + await validateAndPersistApiKey(deps, key, baseUrl || store.resolveBaseUrl()); printQuickStart(); } }, diff --git a/packages/commands/src/commands/auth/logout.ts b/packages/commands/src/commands/auth/logout.ts index f2d4990..3d0b2bc 100644 --- a/packages/commands/src/commands/auth/logout.ts +++ b/packages/commands/src/commands/auth/logout.ts @@ -1,20 +1,6 @@ -import { - defineCommand, - clearApiKey, - readConfigFile, - writeConfigFile, - getConfigPath, -} from "bailian-cli-core"; +import { defineCommand, getConfigPath } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; -async function clearConsoleToken(): Promise { - const file = readConfigFile() as Record; - if (!file.access_token) return false; - delete file.access_token; - await writeConfigFile(file); - return true; -} - export default defineCommand({ description: "Clear stored credentials", auth: "none", @@ -28,21 +14,20 @@ export default defineCommand({ }, exampleArgs: ["", "--console", "--dry-run", "--yes"], async run(ctx) { - const { config, flags } = ctx; - const file = readConfigFile(); + const { settings, flags } = ctx; + const store = ctx.authStore(); + const stored = store.stored(); if (flags.console) { - const hasToken = !!file.access_token; - if (config.dryRun) { - if (hasToken) emitBare("Would clear access_token from ~/.bailian/config.json"); + if (settings.dryRun) { + if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json"); else emitBare("No console access_token to clear."); emitBare("No changes made."); return; } - if (hasToken) { - await clearConsoleToken(); + if (await store.logout("console")) { process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`); - if (file.api_key) { + if (stored.apiKey) { process.stderr.write( "api_key is still configured and will be used for authentication.\n", ); @@ -53,17 +38,16 @@ export default defineCommand({ return; } - const hasKey = !!(file.api_key || file.access_token); + const hasKey = stored.apiKey || stored.console; - if (config.dryRun) { + if (settings.dryRun) { if (hasKey) emitBare("Would clear api_key / access_token from ~/.bailian/config.json"); else emitBare("No credentials to clear."); emitBare("No changes made."); return; } - if (hasKey) { - await clearApiKey(); + if (await store.logout("all")) { process.stderr.write("Cleared api_key / access_token from ~/.bailian/config.json\n"); } else { process.stderr.write("No credentials to clear.\n"); diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index 1fcd705..ec6113f 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -1,4 +1,4 @@ -import { defineCommand, describeAuth, detectOutputFormat, maskToken } from "bailian-cli-core"; +import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { API_KEY_PAGE } from "bailian-cli-runtime"; @@ -16,9 +16,9 @@ export default defineCommand({ consoleSwitchAgent: { type: "number", valueHint: "", description: "Switch agent UID" }, }, async run(ctx) { - const { config } = ctx; - const format = detectOutputFormat(config.output); - const auth = await describeAuth(config); + const { identity, settings } = ctx; + const format = detectOutputFormat(settings.output); + const auth = ctx.authStore().describe(); const apiKey = auth.apiKey ? { @@ -44,8 +44,8 @@ export default defineCommand({ authenticated: false, message: "Not authenticated.", hint: [ - `API key (model): ${config.binName} auth login --api-key or DASHSCOPE_API_KEY`, - `Console gateway: ${config.binName} auth login --console`, + `API key (model): ${identity.binName} auth login --api-key or DASHSCOPE_API_KEY`, + `Console gateway: ${identity.binName} auth login --console`, `Get API Key: ${API_KEY_PAGE}`, ].join("\n"), }, @@ -70,7 +70,7 @@ export default defineCommand({ ` Console gateway: ${consoleCred.source} ${consoleCred.masked} (${consoleCred.region}, ${consoleCred.site})`, ); } else { - emitBare(` Console gateway: not configured (run ${config.binName} auth login --console)`); + emitBare(` Console gateway: not configured (run ${identity.binName} auth login --console)`); } }, }); diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index d6c33c2..aa9f715 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -2,10 +2,9 @@ import { defineCommand, detectOutputFormat, maskToken, - readConfigFile, - writeConfigFile, BailianError, ExitCode, + type ConfigFile, } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; @@ -63,7 +62,7 @@ export default defineCommand({ "--key base_url --value https://dashscope.aliyuncs.com", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const key = flags.key; const value = flags.value; @@ -95,21 +94,18 @@ export default defineCommand({ } } - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ would_set: { [resolvedKey]: value } }, format); return; } - const existing = readConfigFile() as Record; - existing[resolvedKey] = resolvedKey === "timeout" ? Number(value) : value; - await writeConfigFile(existing); + const coerced = resolvedKey === "timeout" ? Number(value) : value; + await ctx.configStore().write({ [resolvedKey]: coerced } as Partial); - if (!config.quiet) { - const shown = SECRET_KEYS.has(resolvedKey) - ? maskToken(String(existing[resolvedKey])) - : existing[resolvedKey]; + if (!settings.quiet) { + const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced; emitResult({ [resolvedKey]: shown }, format); } }, diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index 14d1769..11d5a68 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -1,10 +1,4 @@ -import { - defineCommand, - readConfigFile as loadConfigFile, - getConfigPath, - detectOutputFormat, - maskToken, -} from "bailian-cli-core"; +import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; export default defineCommand({ @@ -12,16 +6,17 @@ export default defineCommand({ auth: "none", exampleArgs: ["", "--output json"], async run(ctx) { - const { config } = ctx; - const file = loadConfigFile(); - const format = detectOutputFormat(config.output); + const { settings, client } = ctx; + const store = ctx.configStore(); + const file = store.read(); + const format = detectOutputFormat(settings.output); const result: Record = { ...file, - base_url: config.baseUrl, - output: config.output, - timeout: config.timeout, - config_file: getConfigPath(), + base_url: client.baseUrl, + output: settings.output, + timeout: settings.timeout, + config_file: store.path, }; if (typeof result.api_key === "string") result.api_key = maskToken(result.api_key); diff --git a/packages/commands/src/commands/console/call.ts b/packages/commands/src/commands/console/call.ts index f35d387..8e0650f 100644 --- a/packages/commands/src/commands/console/call.ts +++ b/packages/commands/src/commands/console/call.ts @@ -36,7 +36,7 @@ export default defineCommand({ `--api some.api.name --data '{"key":"value"}' --console-region cn-beijing`, ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const api = flags.api; const dataRaw = flags.data; @@ -47,14 +47,14 @@ export default defineCommand({ throw new UsageError("--data must be valid JSON"); } - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult( { api, data, - ...effectiveConsoleGatewayConfig(config), + ...effectiveConsoleGatewayConfig(settings), }, format, ); diff --git a/packages/commands/src/commands/file/upload.ts b/packages/commands/src/commands/file/upload.ts index a26069b..6a76401 100644 --- a/packages/commands/src/commands/file/upload.ts +++ b/packages/commands/src/commands/file/upload.ts @@ -26,20 +26,20 @@ export default defineCommand({ "--file cat.png --model qwen-image-2.0", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const filePath = flags.file; const model = flags.model; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ action: "upload", file: filePath, model }, format); return; } const ossUrl = await ctx.client.uploadFile(filePath, model); - if (config.quiet) { + if (settings.quiet) { emitBare(ossUrl); } else { emitResult( diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index b52c472..716a772 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -82,7 +82,7 @@ export default defineCommand({ '--image ./photo.png --prompt "Replace the background with a beach" --watermark false', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; // Normalize --image to string array (supports both single and repeated flags) let rawImages: string[] = []; if (Array.isArray(flags.image)) { @@ -92,7 +92,7 @@ export default defineCommand({ } const prompt = flags.prompt; - const model = flags.model || config.defaultImageModel || "qwen-image-2.0"; + const model = flags.model || settings.defaultImageModel || "qwen-image-2.0"; // Auto-upload local files (resolve all images in parallel) const resolvedImages = await Promise.all( @@ -133,20 +133,20 @@ export default defineCommand({ // Remove undefined parameters stripUndefined(body.parameters as Record); - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}] [Mode: sync] [Images: ${resolvedImages.length}]\n`); } - const concurrent = getConcurrency(flags); + const concurrent = getConcurrency(settings); - const results = await runConcurrent(concurrent, config, () => + const results = await runConcurrent(concurrent, settings, () => ctx.client.requestJson({ path: imageSyncPath(), method: "POST", @@ -165,7 +165,7 @@ export default defineCommand({ throw new BailianError("Edit completed but no images returned.", ExitCode.GENERAL); } - const outDir = resolveOutputDir(config, { + const outDir = resolveOutputDir(settings, { flagDir: flags.outDir, subDir: flags.outDir ? undefined : "images", }); @@ -181,9 +181,9 @@ export default defineCommand({ }) : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; - const saved = await downloadParallel(items, downloadFile, { quiet: config.quiet }); + const saved = await downloadParallel(items, downloadFile, { quiet: settings.quiet }); - if (config.quiet) { + if (settings.quiet) { emitBare(saved.join("\n")); } else { emitResult({ urls: imageUrls, saved, total: imageUrls.length }, format); diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index bbfe56b..7d46d53 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -5,9 +5,9 @@ import { taskPath, detectOutputFormat, type Client, - type Config, + type Settings, type FlagsDef, - type Flags, + type ParsedFlags, resolveOutputDir, type DashScopeImageRequest, type DashScopeImageSyncResponse, @@ -89,7 +89,7 @@ const GENERATE_FLAGS = { description: "Polling interval when waiting (default: 3)", }, } satisfies FlagsDef; -type GenerateFlags = Flags; +type GenerateFlags = ParsedFlags; export default defineCommand({ description: "Generate images (Qwen-Image / wan2.x)", @@ -108,16 +108,16 @@ export default defineCommand({ '--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const prompt = flags.prompt; - const model = flags.model || config.defaultImageModel || "qwen-image-2.0"; + const model = flags.model || settings.defaultImageModel || "qwen-image-2.0"; const useSync = isSyncModel(model); const defaultSize = useSync ? "1:1" : "1:1"; const sizeInput = flags.size || defaultSize; const size = resolveImageSize(sizeInput, useSync); const n = flags.n ?? 1; - const concurrent = getConcurrency(flags); + const concurrent = getConcurrency(settings); const promptExtend = resolveBooleanFlag( flags.promptExtend, @@ -142,21 +142,21 @@ export default defineCommand({ }, }; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body, mode: useSync ? "sync" : "async" }, format); return; } - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`); } if (useSync) { - await handleSyncMode(ctx.client, config, model, body, flags, format, concurrent); + await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent); } else { - await handleAsyncMode(ctx.client, config, model, body, flags, format, concurrent); + await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent); } }, }); @@ -165,14 +165,14 @@ export default defineCommand({ async function handleSyncMode( client: Client, - config: Config, + settings: Settings, _model: string, body: DashScopeImageRequest, flags: GenerateFlags, format: string, concurrent: number, ): Promise { - const results = await runConcurrent(concurrent, config, () => + const results = await runConcurrent(concurrent, settings, () => client.requestJson({ path: imageSyncPath(), method: "POST", body }), ); @@ -186,14 +186,14 @@ async function handleSyncMode( throw new BailianError("Generation completed but no images returned.", ExitCode.GENERAL); } - await saveImages(imageUrls, flags, config, format); + await saveImages(imageUrls, flags, settings, format); } // ---- Async mode: wan2.x / qwen-image-plus ---- async function handleAsyncMode( client: Client, - config: Config, + settings: Settings, _model: string, body: DashScopeImageRequest, flags: GenerateFlags, @@ -202,7 +202,7 @@ async function handleAsyncMode( ): Promise { const responses = await runConcurrent( concurrent, - config, + settings, () => client.requestJson({ path: imagePath(), @@ -215,7 +215,7 @@ async function handleAsyncMode( const taskIds = responses.map((r) => r.output.task_id); // --no-wait: return all task IDs immediately - if (flags.noWait || config.async) { + if (flags.noWait || settings.async) { emitResult({ task_ids: taskIds }, format as OutputFormat); return; } @@ -225,10 +225,10 @@ async function handleAsyncMode( const pollPromises = taskIds.map((taskId) => { const pollUrl = client.url(taskPath(taskId)); - return poll(config, { + return poll(client, settings, { url: pollUrl, intervalSec: pollInterval, - timeoutSec: config.timeout, + timeoutSec: settings.timeout, isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, @@ -265,7 +265,7 @@ async function handleAsyncMode( await saveImages( imageUrls, flags, - config, + settings, format, taskIds.length === 1 ? taskIds[0] : undefined, taskIds, @@ -277,12 +277,12 @@ async function handleAsyncMode( async function saveImages( imageUrls: string[], flags: GenerateFlags, - config: Config, + settings: Settings, format: string, taskId?: string, taskIds?: string[], ): Promise { - const outDir = resolveOutputDir(config, { + const outDir = resolveOutputDir(settings, { flagDir: flags.outDir, subDir: flags.outDir ? undefined : "images", }); @@ -299,9 +299,9 @@ async function saveImages( }) : [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }]; - const results = await downloadParallel(items, downloadFile, { quiet: config.quiet }); + const results = await downloadParallel(items, downloadFile, { quiet: settings.quiet }); - if (config.quiet) { + if (settings.quiet) { emitBare(results.join("\n")); } else { const output: Record = { diff --git a/packages/commands/src/commands/knowledge/retrieve.ts b/packages/commands/src/commands/knowledge/retrieve.ts index 6732861..2c9464f 100644 --- a/packages/commands/src/commands/knowledge/retrieve.ts +++ b/packages/commands/src/commands/knowledge/retrieve.ts @@ -65,8 +65,8 @@ export default defineCommand({ '--index-id idx_xxx --query "RAG retrieval" --rerank --rerank-model qwen3-rerank-hybrid', ], async run(ctx) { - const { config, flags } = ctx; - const format = detectOutputFormat(config.output); + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); if (flags.topK !== undefined && flags.rerankTopN === undefined) { process.stderr.write("Warning: --top-k is deprecated. Use --rerank-top-n instead.\n"); @@ -95,7 +95,7 @@ export default defineCommand({ body.rerank = [rerankEntry]; } - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(knowledgeRetrievePath()), request: body }, format); return; } @@ -107,7 +107,7 @@ export default defineCommand({ }); const nodes = response.data?.nodes || []; - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { emitTextNodes(nodes.map((n) => ({ text: n.text, score: n.score }))); } else { emitResult(response, format); diff --git a/packages/commands/src/commands/mcp/call.ts b/packages/commands/src/commands/mcp/call.ts index d641bff..9fa6a82 100644 --- a/packages/commands/src/commands/mcp/call.ts +++ b/packages/commands/src/commands/mcp/call.ts @@ -64,7 +64,7 @@ export default defineCommand({ "--target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const target = flags.target; const dot = target.indexOf("."); @@ -91,9 +91,9 @@ export default defineCommand({ if (flags.query !== undefined) toolArgs.query = flags.query; const url = flags.url || ctx.client.url(bailianMcpPath(serverCode)); - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult( { server: serverCode, diff --git a/packages/commands/src/commands/mcp/list.ts b/packages/commands/src/commands/mcp/list.ts index a0d8b72..548ae59 100644 --- a/packages/commands/src/commands/mcp/list.ts +++ b/packages/commands/src/commands/mcp/list.ts @@ -47,12 +47,12 @@ export default defineCommand({ }, exampleArgs: ["", "--name finance", "--output json"], async run(ctx) { - const { config, flags } = ctx; + const { settings, identity, flags } = ctx; const serverName = flags.name || ""; const type = flags.type || "OFFICIAL"; const pageNo = flags.page || 1; const pageSize = flags.pageSize || 30; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const data = { reqDTO: { @@ -65,8 +65,8 @@ export default defineCommand({ }, }; - if (config.dryRun) { - emitResult({ api: MCP_LIST_API, data, ...effectiveConsoleGatewayConfig(config) }, format); + if (settings.dryRun) { + emitResult({ api: MCP_LIST_API, data, ...effectiveConsoleGatewayConfig(settings) }, format); return; } @@ -78,7 +78,7 @@ export default defineCommand({ const msg = (dataField.errorMsg as string | undefined) ?? code; const hint = code === "BailianGateway.Login.NotLogined" - ? `Run \`${config.binName} auth login --console\` to refresh your console session.` + ? `Run \`${identity.binName} auth login --console\` to refresh your console session.` : undefined; throw new BailianError(`Console gateway: ${msg}`, ExitCode.AUTH, hint); } diff --git a/packages/commands/src/commands/mcp/tools.ts b/packages/commands/src/commands/mcp/tools.ts index 152e053..bc69128 100644 --- a/packages/commands/src/commands/mcp/tools.ts +++ b/packages/commands/src/commands/mcp/tools.ts @@ -24,13 +24,13 @@ export default defineCommand({ "--server my-server --url https://example.com/mcp", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const code = flags.server; const url = flags.url || ctx.client.url(bailianMcpPath(code)); - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ server: code, url, action: "tools/list" }, format); return; } diff --git a/packages/commands/src/commands/memory/add.ts b/packages/commands/src/commands/memory/add.ts index 00a3edd..160999f 100644 --- a/packages/commands/src/commands/memory/add.ts +++ b/packages/commands/src/commands/memory/add.ts @@ -4,7 +4,7 @@ import { memoryAddPath, detectOutputFormat, type FlagsDef, - type Flags, + type ParsedFlags, type MemoryAddRequest, type MemoryAddResponse, } from "bailian-cli-core"; @@ -29,7 +29,7 @@ const ADD_FLAGS = { description: "Memory library ID (isolate memory space)", }, } satisfies FlagsDef; -type AddFlags = Flags; +type AddFlags = ParsedFlags; export default defineCommand({ description: "Add memory from messages or custom content", @@ -44,7 +44,7 @@ export default defineCommand({ validate: (f: AddFlags) => !f.messages && !f.content ? "Provide --messages or --content." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const userId = flags.userId; const body: MemoryAddRequest = { user_id: userId }; @@ -64,9 +64,9 @@ export default defineCommand({ if (flags.profileSchema) body.profile_schema = flags.profileSchema; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(memoryAddPath()), request: body }, format); return; } @@ -77,7 +77,7 @@ export default defineCommand({ body, }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { const ids = response.memory_ids?.join(", ") || "none"; emitBare(`Memory added. IDs: ${ids}`); } else { diff --git a/packages/commands/src/commands/memory/delete.ts b/packages/commands/src/commands/memory/delete.ts index 69b174d..0c85ddc 100644 --- a/packages/commands/src/commands/memory/delete.ts +++ b/packages/commands/src/commands/memory/delete.ts @@ -26,16 +26,16 @@ export default defineCommand({ }, exampleArgs: ["--node-id node_xxx --user-id user1"], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const nodeId = flags.nodeId; const userId = flags.userId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const params = new URLSearchParams({ user_id: userId }); if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId); const path = `${memoryNodePath(nodeId)}?${params.toString()}`; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(path), method: "DELETE" }, format); return; } @@ -45,7 +45,7 @@ export default defineCommand({ method: "DELETE", }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { emitBare(`Memory node ${nodeId} deleted.`); } else { emitResult(response, format); diff --git a/packages/commands/src/commands/memory/list.ts b/packages/commands/src/commands/memory/list.ts index 136ef47..6757fe6 100644 --- a/packages/commands/src/commands/memory/list.ts +++ b/packages/commands/src/commands/memory/list.ts @@ -27,10 +27,10 @@ export default defineCommand({ }, exampleArgs: ["--user-id user1", "--user-id user1 --page-size 20 --page 2"], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const userId = flags.userId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const params = new URLSearchParams(); params.set("user_id", userId); if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize)); @@ -39,7 +39,7 @@ export default defineCommand({ const path = `${memoryListPath()}?${params.toString()}`; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format); return; } @@ -49,7 +49,7 @@ export default defineCommand({ method: "GET", }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { if (!response.memory_nodes || response.memory_nodes.length === 0) { emitBare("No memory nodes found."); } else { diff --git a/packages/commands/src/commands/memory/profile-create.ts b/packages/commands/src/commands/memory/profile-create.ts index 065e48b..391fd08 100644 --- a/packages/commands/src/commands/memory/profile-create.ts +++ b/packages/commands/src/commands/memory/profile-create.ts @@ -31,7 +31,7 @@ export default defineCommand({ '--name "user_basic" --attributes \'[{"name":"age","description":"age"},{"name":"hobby","description":"hobby"}]\'', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const name = flags.name; const attrStr = flags.attributes; @@ -45,9 +45,9 @@ export default defineCommand({ const body: ProfileSchemaCreateRequest = { name, attributes }; if (flags.description) body.description = flags.description; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(profileSchemaPath()), request: body }, format); return; } @@ -58,7 +58,7 @@ export default defineCommand({ body, }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { emitBare(`Profile schema created: ${response.profile_schema_id}`); } else { emitResult(response, format); diff --git a/packages/commands/src/commands/memory/profile-get.ts b/packages/commands/src/commands/memory/profile-get.ts index 66d1ca3..0a891ad 100644 --- a/packages/commands/src/commands/memory/profile-get.ts +++ b/packages/commands/src/commands/memory/profile-get.ts @@ -26,15 +26,15 @@ export default defineCommand({ }, exampleArgs: ["--schema-id schema_xxx --user-id user1"], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const schemaId = flags.schemaId; const userId = flags.userId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const params = new URLSearchParams({ user_id: userId }); const path = `${userProfilePath(schemaId)}?${params.toString()}`; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(path), method: "GET" }, format); return; } @@ -44,7 +44,7 @@ export default defineCommand({ method: "GET", }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { if (response.profile?.attributes) { for (const attr of response.profile.attributes) { emitBare(`${attr.name}: ${attr.value ?? "(empty)"}`); diff --git a/packages/commands/src/commands/memory/search.ts b/packages/commands/src/commands/memory/search.ts index 9f9fa8d..6abccb4 100644 --- a/packages/commands/src/commands/memory/search.ts +++ b/packages/commands/src/commands/memory/search.ts @@ -4,7 +4,7 @@ import { memorySearchPath, detectOutputFormat, type FlagsDef, - type Flags, + type ParsedFlags, type MemorySearchRequest, type MemorySearchResponse, } from "bailian-cli-core"; @@ -25,7 +25,7 @@ const SEARCH_FLAGS = { }, memoryLibraryId: { type: "string", valueHint: "", description: "Memory library ID" }, } satisfies FlagsDef; -type SearchFlags = Flags; +type SearchFlags = ParsedFlags; export default defineCommand({ description: "Search memory nodes by query or messages", @@ -39,7 +39,7 @@ export default defineCommand({ validate: (f: SearchFlags) => !f.query && !f.messages ? "Provide --query or --messages." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const userId = flags.userId; const body: MemorySearchRequest = { user_id: userId }; @@ -62,9 +62,9 @@ export default defineCommand({ if (flags.topK !== undefined) body.top_k = flags.topK; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(memorySearchPath()), request: body }, format); return; } @@ -75,7 +75,7 @@ export default defineCommand({ body, }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { if (!response.memory_nodes || response.memory_nodes.length === 0) { emitBare("No memory nodes found."); } else { diff --git a/packages/commands/src/commands/memory/update.ts b/packages/commands/src/commands/memory/update.ts index 6951480..14cd3e9 100644 --- a/packages/commands/src/commands/memory/update.ts +++ b/packages/commands/src/commands/memory/update.ts @@ -37,7 +37,7 @@ export default defineCommand({ }, exampleArgs: ['--node-id node_xxx --user-id user1 --content "updated memory content"'], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const nodeId = flags.nodeId; const userId = flags.userId; const content = flags.content; @@ -48,9 +48,9 @@ export default defineCommand({ }; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult( { endpoint: ctx.client.url(memoryNodePath(nodeId)), method: "PATCH", request: body }, format, @@ -64,7 +64,7 @@ export default defineCommand({ body, }); - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { emitBare(`Memory node ${nodeId} updated.`); } else { emitResult(response, format); diff --git a/packages/commands/src/commands/omni/chat.ts b/packages/commands/src/commands/omni/chat.ts index 7a0dff7..c9b8c37 100644 --- a/packages/commands/src/commands/omni/chat.ts +++ b/packages/commands/src/commands/omni/chat.ts @@ -144,15 +144,15 @@ export default defineCommand({ '--message "Read this passage aloud" --audio-out greeting.wav', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; // --- Parse messages --- const userMessages = flags.message; - const model = flags.model || config.defaultOmniModel || "qwen3.5-omni-plus"; + const model = flags.model || settings.defaultOmniModel || "qwen3.5-omni-plus"; const voice = flags.voice || "Cherry"; const audioFormat = flags.audioFormat || "wav"; const textOnly = flags.textOnly === true; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); // --- Build messages array --- const allMessages: ChatMessage[] = []; @@ -278,12 +278,12 @@ export default defineCommand({ if (flags.maxTokens !== undefined) body.max_tokens = flags.maxTokens; if (flags.temperature !== undefined) body.temperature = flags.temperature; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } - if (!config.quiet) { + if (!settings.quiet) { const modeLabel = textOnly ? "text-only" : `text+audio, voice: ${voice}`; process.stderr.write(`[Model: ${model}] [${modeLabel}]\n`); } @@ -342,7 +342,7 @@ export default defineCommand({ if (!destPath) { // eslint-disable-next-line @typescript-eslint/unbound-method const { join } = await import("path"); - const destDir = resolveOutputDir(config, { subDir: "omni" }); + const destDir = resolveOutputDir(settings, { subDir: "omni" }); const timestamp = Date.now(); destPath = join(destDir, `omni_${timestamp}.wav`); } @@ -350,7 +350,7 @@ export default defineCommand({ writeFileSync(destPath, wavBuffer); audioSaved = destPath; - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`Audio saved: ${destPath}\n`); } } diff --git a/packages/commands/src/commands/pipeline/run.ts b/packages/commands/src/commands/pipeline/run.ts index 73a0489..736f6a1 100644 --- a/packages/commands/src/commands/pipeline/run.ts +++ b/packages/commands/src/commands/pipeline/run.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { defineCommand, type FlagsDef, type Flags } from "bailian-cli-core"; +import { defineCommand, type FlagsDef, type ParsedFlags } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; import { initPipelineSteps } from "bailian-cli-runtime"; import { executePipeline, streamPipelineEvents } from "bailian-cli-runtime"; @@ -37,7 +37,7 @@ const RUN_FLAGS = { description: "Default step timeout in seconds", }, } satisfies FlagsDef; -type RunFlags = Flags; +type RunFlags = ParsedFlags; export default defineCommand({ description: "Run a pipeline workflow definition", @@ -53,7 +53,7 @@ export default defineCommand({ ], validate: (f) => (f.input && f.inputFile ? "use --input or --input-file, not both" : undefined), async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const file = flags.file; initPipelineSteps(); @@ -69,7 +69,7 @@ export default defineCommand({ for await (const event of streamPipelineEvents(pipeline, runtimeInput, { concurrency: flags.concurrency, basePath, - dryRun: flags.dryRun, + dryRun: settings.dryRun, timeoutSeconds: flags.timeout, })) { process.stdout.write(JSON.stringify(event) + "\n"); @@ -80,12 +80,12 @@ export default defineCommand({ const report = await executePipeline(pipeline, runtimeInput, { concurrency: flags.concurrency, basePath, - dryRun: flags.dryRun, + dryRun: settings.dryRun, timeoutSeconds: flags.timeout, - onEvent: flags.verbose ? logEvent : undefined, + onEvent: settings.verbose ? logEvent : undefined, }); - if (config.output === "json") { + if (settings.output === "json") { emitResult(report, "json"); } else { printTextReport(report); diff --git a/packages/commands/src/commands/pipeline/validate.ts b/packages/commands/src/commands/pipeline/validate.ts index 593e46a..695b877 100644 --- a/packages/commands/src/commands/pipeline/validate.ts +++ b/packages/commands/src/commands/pipeline/validate.ts @@ -19,7 +19,7 @@ export default defineCommand({ }, exampleArgs: ["--file workflow.yaml", "--file workflow.json --output json"], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const file = flags.file; initPipelineSteps(); @@ -29,7 +29,7 @@ export default defineCommand({ const issues = collectPipelineIssues(pipeline); const hints = issues.length === 0 ? collectPipelineHints(pipeline) : []; - if (config.output === "json") { + if (settings.output === "json") { emitResult( { valid: issues.length === 0, issues, ...(hints.length > 0 ? { hints } : {}) }, "json", diff --git a/packages/commands/src/commands/quota/check.ts b/packages/commands/src/commands/quota/check.ts index 1b4be03..6b868d9 100644 --- a/packages/commands/src/commands/quota/check.ts +++ b/packages/commands/src/commands/quota/check.ts @@ -257,16 +257,16 @@ export default defineCommand({ validate: (f) => (Number(f.period) || 2) < 1 ? "--period must be at least 1 minute." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const windowMinutes = Number(flags.period) || 2; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult( { apis: [MODEL_LIST_API, MONITOR_API], - ...effectiveConsoleGatewayConfig(config), + ...effectiveConsoleGatewayConfig(settings), }, format, ); @@ -320,6 +320,6 @@ export default defineCommand({ return; } - printTable(checkRows, config.noColor); + printTable(checkRows, settings.noColor); }, }); diff --git a/packages/commands/src/commands/quota/history.ts b/packages/commands/src/commands/quota/history.ts index 488c86e..cda038b 100644 --- a/packages/commands/src/commands/quota/history.ts +++ b/packages/commands/src/commands/quota/history.ts @@ -112,17 +112,17 @@ export default defineCommand({ }, exampleArgs: ["", "--page 2", "--page-size 20", "--model qwen-turbo", "--output json"], async run(ctx) { - const { config, flags } = ctx; + const { identity, settings, flags } = ctx; const page = Number(flags.page) || 1; const pageSize = Number(flags.pageSize) || 10; const modelFilter = flags.model || undefined; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const requestData = { input: { pageNo: page, pageSize }, }; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ api: HISTORY_API, data: requestData }, format); return; } @@ -135,7 +135,7 @@ export default defineCommand({ throw new BailianError( "session expired.", ExitCode.AUTH, - `Run \`${config.binName} auth login --console\` to re-authenticate.`, + `Run \`${identity.binName} auth login --console\` to re-authenticate.`, ); } throw err; @@ -164,6 +164,6 @@ export default defineCommand({ return; } - printTable(records, config.noColor, modelFilter ? records.length : total); + printTable(records, settings.noColor, modelFilter ? records.length : total); }, }); diff --git a/packages/commands/src/commands/quota/list.ts b/packages/commands/src/commands/quota/list.ts index a36ad35..c3351d7 100644 --- a/packages/commands/src/commands/quota/list.ts +++ b/packages/commands/src/commands/quota/list.ts @@ -168,12 +168,12 @@ export default defineCommand({ "--output json", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const showAll = Boolean(flags.all); - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { const input: Record = { pageNo: 1, pageSize: 50, @@ -224,6 +224,6 @@ export default defineCommand({ return; } - printTable(models, config.noColor); + printTable(models, settings.noColor); }, }); diff --git a/packages/commands/src/commands/quota/request.ts b/packages/commands/src/commands/quota/request.ts index 8338daf..9a6a5a7 100644 --- a/packages/commands/src/commands/quota/request.ts +++ b/packages/commands/src/commands/quota/request.ts @@ -105,13 +105,13 @@ export default defineCommand({ ], validate: (f) => (Number(f.tpm) > 0 ? undefined : "--tpm must be a positive number."), async run(ctx) { - const { config, flags } = ctx; + const { identity, settings, flags } = ctx; const modelName = flags.model; const tpmValue = Number(flags.tpm); - const autoConfirm = Boolean(flags.yes) || config.yes; - const format = detectOutputFormat(config.output); + const autoConfirm = Boolean(flags.yes) || settings.yes; + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { const requestData = { input: { model: modelName, @@ -127,7 +127,7 @@ export default defineCommand({ throw new BailianError( `model "${modelName}" not found or does not support self-service quota increase.`, ExitCode.GENERAL, - `Run \`${config.binName} quota list\` to view available models.`, + `Run \`${identity.binName} quota list\` to view available models.`, ); } @@ -163,7 +163,7 @@ export default defineCommand({ throw new BailianError( "session expired.", ExitCode.AUTH, - `Run \`${config.binName} auth login --console\` to re-authenticate.`, + `Run \`${identity.binName} auth login --console\` to re-authenticate.`, ); } throw err; diff --git a/packages/commands/src/commands/search/web.ts b/packages/commands/src/commands/search/web.ts index 8591e21..9804527 100644 --- a/packages/commands/src/commands/search/web.ts +++ b/packages/commands/src/commands/search/web.ts @@ -31,12 +31,12 @@ export default defineCommand({ ], validate: (f) => (!f.listTools && !f.query ? "Missing required flag: --query" : undefined), async run(ctx) { - const { config, flags } = ctx; - const format = detectOutputFormat(config.output); + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); // --- List tools mode --- if (flags.listTools) { - if (config.dryRun) { + if (settings.dryRun) { emitResult({ endpoint: ctx.client.url(mcpWebSearchPath()), action: "tools/list" }, format); return; } @@ -52,7 +52,7 @@ export default defineCommand({ // --- Search mode --- const query = flags.query; - if (config.dryRun) { + if (settings.dryRun) { emitResult( { endpoint: ctx.client.url(mcpWebSearchPath()), @@ -72,12 +72,12 @@ export default defineCommand({ const client = ctx.client.mcp(mcpWebSearchPath()); const spinner = createSpinner("Initializing search..."); - if (!config.quiet) spinner.start(); + if (!settings.quiet) spinner.start(); try { await client.initialize(); - if (!config.quiet) spinner.update("Searching..."); + if (!settings.quiet) spinner.update("Searching..."); // Build tool arguments const toolArgs: Record = { query: query! }; @@ -92,7 +92,7 @@ export default defineCommand({ throw new BailianError(`Search error: ${errText}`); } - if (!config.quiet) spinner.stop("Done."); + if (!settings.quiet) spinner.stop("Done."); // Output results — always structured to stdout if (format === "json") { diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index eac24e0..60c2035 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -5,7 +5,7 @@ import { ExitCode, detectOutputFormat, type Client, - type Config, + type Settings, type DashScopeASRRequest, type DashScopeASRTaskResult, type DashScopeAsyncResponse, @@ -15,7 +15,7 @@ import { speechRecognizePath, type OutputFormat, type FlagsDef, - type Flags, + type ParsedFlags, } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; @@ -53,7 +53,7 @@ const RECOGNIZE_FLAGS = { description: "Polling interval in seconds (default: 2)", }, } satisfies FlagsDef; -type RecognizeFlags = Flags; +type RecognizeFlags = ParsedFlags; export default defineCommand({ description: "Recognize speech from audio files (FunAudio-ASR)", @@ -70,7 +70,7 @@ export default defineCommand({ "--url https://example.com/audio.mp3 --no-wait --quiet", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; // Normalize --url to string[] (supports both single and repeated flags) let rawUrls: string[] = []; if (Array.isArray(flags.url)) { @@ -90,7 +90,7 @@ export default defineCommand({ } const model = flags.model || "fun-asr"; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); // Auto-upload local files in parallel const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model))); @@ -115,22 +115,22 @@ export default defineCommand({ // Remove undefined parameter fields stripUndefined(body.parameters as Record); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body, mode: "async" }, format); return; } - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}] [Mode: async] [Files: ${resolvedUrls.length}]\n`); } - await handleAsyncMode(ctx.client, config, body, flags, format, resolvedUrls.length); + await handleAsyncMode(ctx.client, settings, body, flags, format, resolvedUrls.length); }, }); async function handleAsyncMode( client: Client, - config: Config, + settings: Settings, body: DashScopeASRRequest, flags: RecognizeFlags, format: OutputFormat, @@ -147,7 +147,7 @@ async function handleAsyncMode( const taskId = response.output.task_id; // --no-wait: return task ID immediately - if (flags.noWait || config.async) { + if (flags.noWait || settings.async) { emitResult({ task_id: taskId }, format); return; } @@ -156,10 +156,10 @@ async function handleAsyncMode( const pollInterval = flags.pollInterval ?? 2; const pollUrl = client.url(taskPath(taskId)); - const result = await poll(config, { + const result = await poll(client, settings, { url: pollUrl, intervalSec: pollInterval, - timeoutSec: config.timeout, + timeoutSec: settings.timeout, isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED", isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED", getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status, @@ -244,7 +244,7 @@ async function handleAsyncMode( const outPath = flags.out; const outData = allTransData.length === 1 ? allTransData[0] : allTransData; writeFileSync(outPath, JSON.stringify(outData, null, 2) + "\n"); - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`Full result saved to: ${outPath}\n`); } } diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index 27cae16..d372f64 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -5,7 +5,7 @@ import { ExitCode, detectOutputFormat, type Client, - type Config, + type Settings, type DashScopeTTSRequest, type DashScopeTTSResponse, type DashScopeTTSStreamChunk, @@ -16,7 +16,7 @@ import { resolveOutputDir, DOCS_HOSTS, type FlagsDef, - type Flags, + type ParsedFlags, } from "bailian-cli-core"; const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`; @@ -207,7 +207,7 @@ const SYNTHESIZE_FLAGS = { }, stream: { type: "switch", description: "Stream raw PCM audio to stdout (pipe to player)" }, } satisfies FlagsDef; -type SynthesizeFlags = Flags; +type SynthesizeFlags = ParsedFlags; export default defineCommand({ description: "Synthesize speech from text (CosyVoice TTS)", @@ -233,8 +233,8 @@ export default defineCommand({ return undefined; }, async run(ctx) { - const { config, flags } = ctx; - const model = flags.model || config.defaultSpeechModel || "cosyvoice-v3-flash"; + const { settings, flags } = ctx; + const model = flags.model || settings.defaultSpeechModel || "cosyvoice-v3-flash"; // --list-voices: print voice list for the model and exit if (flags.listVoices) { @@ -265,7 +265,7 @@ export default defineCommand({ const enableSsml = flags.enableSsml === true ? true : undefined; const useStream = flags.stream === true; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const body: DashScopeTTSRequest = { model, @@ -287,33 +287,33 @@ export default defineCommand({ // Remove undefined fields from input stripUndefined(body.input as Record); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`); } if (useStream) { - await handleStreamMode(ctx.client, config, body, flags, format); + await handleStreamMode(ctx.client, settings, body, flags, format); } else { - await handleNonStreamMode(ctx.client, config, body, flags, format); + await handleNonStreamMode(ctx.client, settings, body, flags, format); } }, }); async function handleNonStreamMode( client: Client, - config: Config, + settings: Settings, body: DashScopeTTSRequest, flags: SynthesizeFlags, format: OutputFormat, ): Promise { - const concurrent = getConcurrency(flags); + const concurrent = getConcurrency(settings); - const results = await runConcurrent(concurrent, config, () => + const results = await runConcurrent(concurrent, settings, () => client.requestJson({ path: speechSynthesizePath(), method: "POST", @@ -329,7 +329,7 @@ async function handleNonStreamMode( // Determine output paths const path = await import("path"); - const destDir = resolveOutputDir(config, { subDir: "speech" }); + const destDir = resolveOutputDir(settings, { subDir: "speech" }); const items = audioUrls.map((audioUrl, i) => { let destPath = flags.out; @@ -344,9 +344,9 @@ async function handleNonStreamMode( return { url: audioUrl, destPath: destPath! }; }); - const saved = await downloadParallel(items, downloadFile, { quiet: config.quiet }); + const saved = await downloadParallel(items, downloadFile, { quiet: settings.quiet }); - if (config.quiet) { + if (settings.quiet) { emitBare(saved.join("\n")); } else if (saved.length === 1) { const expiresAt = results[0]!.output?.audio?.expires_at; @@ -376,7 +376,7 @@ async function handleNonStreamMode( async function handleStreamMode( client: Client, - config: Config, + settings: Settings, body: DashScopeTTSRequest, flags: SynthesizeFlags, format: OutputFormat, @@ -420,7 +420,7 @@ async function handleStreamMode( if (chunk.output?.finish_reason === "stop") { lastAudioUrl = chunk.output?.audio?.url; - if (lastAudioUrl && !config.quiet) { + if (lastAudioUrl && !settings.quiet) { process.stderr.write(`\nFull audio URL: ${lastAudioUrl}\n`); } break; @@ -433,7 +433,7 @@ async function handleStreamMode( writer.on("error", reject); writer.end(); }); - if (!config.quiet && outPath) { + if (!settings.quiet && outPath) { process.stderr.write(`Saved: ${outPath}\n`); } } diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 4ca59d8..941044e 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -8,7 +8,7 @@ import { type ChatResponse, type StreamChunk, type FlagsDef, - type Flags, + type ParsedFlags, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { readFileSync } from "fs"; @@ -53,7 +53,7 @@ const CHAT_FLAGS = { description: "Max tokens for thinking (default: 4096)", }, } satisfies FlagsDef; -type ChatFlags = Flags; +type ChatFlags = ParsedFlags; interface ParsedMessages { system?: string; @@ -121,12 +121,12 @@ export default defineCommand({ validate: (f) => !f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const { system, messages } = parseMessages(flags); - const model = flags.model || config.defaultTextModel || "qwen3.7-max"; + const model = flags.model || settings.defaultTextModel || "qwen3.7-max"; const shouldStream = flags.stream || process.stdout.isTTY; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); // Build messages array with system prompt const allMessages: ChatMessage[] = []; @@ -164,7 +164,7 @@ export default defineCommand({ body.tools = tools; } - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } @@ -180,8 +180,8 @@ export default defineCommand({ let textContent = ""; let inThinking = false; const writesStreamingStdout = format === "text"; - const dim = config.noColor ? "" : "\x1b[2m"; - const reset = config.noColor ? "" : "\x1b[0m"; + const dim = settings.noColor ? "" : "\x1b[2m"; + const reset = settings.noColor ? "" : "\x1b[0m"; const isTTY = process.stdout.isTTY; const statusOut = format === "json" ? process.stderr : isTTY ? process.stdout : process.stderr; @@ -234,7 +234,7 @@ export default defineCommand({ const text = response.choices?.[0]?.message?.content ?? ""; - if (config.quiet || format === "text") { + if (settings.quiet || format === "text") { emitBare(text); } else { emitResult(response, format); diff --git a/packages/commands/src/commands/update.ts b/packages/commands/src/commands/update.ts index e890fa6..363455f 100644 --- a/packages/commands/src/commands/update.ts +++ b/packages/commands/src/commands/update.ts @@ -32,10 +32,10 @@ export default defineCommand({ auth: "none", exampleArgs: [""], async run(ctx) { - const { config } = ctx; - const npmPackage = config.npmPackage!; - const binName = config.binName!; - const currentVersion = config.clientVersion!; + const { identity } = ctx; + const npmPackage = identity.npmPackage; + const binName = identity.binName; + const currentVersion = identity.version; const isTTY = process.stderr.isTTY; const green = isTTY ? "\x1b[32m" : ""; const yellow = isTTY ? "\x1b[33m" : ""; diff --git a/packages/commands/src/commands/usage/free.ts b/packages/commands/src/commands/usage/free.ts index 59b8287..dffd26a 100644 --- a/packages/commands/src/commands/usage/free.ts +++ b/packages/commands/src/commands/usage/free.ts @@ -217,11 +217,11 @@ export default defineCommand({ "--model qwen3-max --console-region cn-beijing", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const expiringDays = Number(flags.expiring) || 0; const sortField = flags.sort || undefined; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); let models: string[]; const typeMap = new Map(); @@ -243,7 +243,7 @@ export default defineCommand({ queryFreeTierQuotaRequest: { models }, }; - if (config.dryRun) { + if (settings.dryRun) { emitResult( { api: FREE_TIER_API, @@ -340,6 +340,6 @@ export default defineCommand({ return; } - printTable(quotas, stopMap, typeMap, config.noColor); + printTable(quotas, stopMap, typeMap, settings.noColor); }, }); diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index d1b636a..e5eceef 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -132,10 +132,10 @@ export default defineCommand({ validate: (f) => !f.model && !f.all ? "Provide --model [,model2,...] or --all." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const modelFlag = flags.model || undefined; const off = Boolean(flags.off); - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); let models: string[]; if (modelFlag) { @@ -156,7 +156,7 @@ export default defineCommand({ ? "BatchDeactivateFreeTierOnlyRequest" : "BatchActivateFreeTierOnlyRequest"; - if (config.dryRun) { + if (settings.dryRun) { emitResult( { api, diff --git a/packages/commands/src/commands/usage/stats.ts b/packages/commands/src/commands/usage/stats.ts index c138293..b247d15 100644 --- a/packages/commands/src/commands/usage/stats.ts +++ b/packages/commands/src/commands/usage/stats.ts @@ -3,7 +3,7 @@ import { BailianError, ExitCode, detectOutputFormat, - type Config, + type Settings, type Client, } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; @@ -92,14 +92,15 @@ async function pollTelemetryApi( return null; } -function resolveWorkspaceId(config: Config, flagWorkspaceId?: string): string { +// 注意:`--workspace-id` 是命令级 flag、不进 settings,flag 的第一优先级须在此显式保住。 +function resolveWorkspaceId(settings: Settings, binName: string, flagWorkspaceId?: string): string { if (flagWorkspaceId) return flagWorkspaceId; - if (config.workspaceId) return config.workspaceId; + if (settings.workspaceId) return settings.workspaceId; throw new BailianError( - `workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${config.binName} config set workspace_id \`.`, + `workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id \`.`, ExitCode.GENERAL, - `Run \`${config.binName} workspace list\` to view available workspaces.`, + `Run \`${binName} workspace list\` to view available workspaces.`, ); } @@ -321,14 +322,14 @@ export default defineCommand({ "--output json", ], async run(ctx) { - const { config, flags } = ctx; + const { identity, settings, flags } = ctx; const modelFlag = flags.model || undefined; const daysFlag = Number(flags.days) || 7; const typeFlag = flags.type || undefined; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const flagWorkspaceId = flags.workspaceId || undefined; - const workspaceId = resolveWorkspaceId(config, flagWorkspaceId); + const workspaceId = resolveWorkspaceId(settings, identity.binName, flagWorkspaceId); const endTime = Date.now(); const startTime = endTime - daysFlag * 24 * 60 * 60 * 1000; @@ -355,7 +356,7 @@ export default defineCommand({ }; if (typeFlag) baseReqDTO.obsModelType = typeFlag; - if (config.dryRun) { + if (settings.dryRun) { emitResult( { api: LIST_API, data: { reqDTO: { ...baseReqDTO, model: models.join(",") } } }, format, @@ -396,7 +397,7 @@ export default defineCommand({ return; } - printModelTable(allItems, startTime, endTime, daysFlag, config.noColor); + printModelTable(allItems, startTime, endTime, daysFlag, settings.noColor); } else { const reqDTO: Record = { startTime, @@ -406,7 +407,7 @@ export default defineCommand({ }; if (typeFlag) reqDTO.obsModelType = typeFlag; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ api: OVERVIEW_API, data: { reqDTO } }, format); return; } @@ -452,7 +453,7 @@ export default defineCommand({ return; } - printOverview(stat, startTime, endTime, daysFlag, config.noColor); + printOverview(stat, startTime, endTime, daysFlag, settings.noColor); } }, }); diff --git a/packages/commands/src/commands/video/download.ts b/packages/commands/src/commands/video/download.ts index 9c4c11c..c11c4eb 100644 --- a/packages/commands/src/commands/video/download.ts +++ b/packages/commands/src/commands/video/download.ts @@ -27,14 +27,14 @@ export default defineCommand({ "--task-id 3b256896-xxxx --out video.mp4 --quiet", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const taskId = flags.taskId; const outPath = flags.out; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ task_id: taskId, action: "download", out: outPath }, format); return; } @@ -59,9 +59,9 @@ export default defineCommand({ throw new BailianError("No download URL available for this task.", ExitCode.GENERAL); } - const { size } = await downloadFile(downloadUrl, outPath, { quiet: config.quiet }); + const { size } = await downloadFile(downloadUrl, outPath, { quiet: settings.quiet }); - if (config.quiet) { + if (settings.quiet) { emitBare(outPath); return; } diff --git a/packages/commands/src/commands/video/edit.ts b/packages/commands/src/commands/video/edit.ts index 24ea86f..266e297 100644 --- a/packages/commands/src/commands/video/edit.ts +++ b/packages/commands/src/commands/video/edit.ts @@ -108,14 +108,14 @@ export default defineCommand({ '--video https://example.com/input.mp4 --prompt "Put clothes on the kitten in the video" --watermark false', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const videoUrl = flags.video; // prompt is optional for video edit per API spec const prompt = flags.prompt; const model = flags.model || "happyhorse-1.0-video-edit"; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); // Auto-upload local files const resolvedVideoUrl = await ctx.client.uploadFile(videoUrl, model); @@ -159,7 +159,7 @@ export default defineCommand({ }, }; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } @@ -174,13 +174,13 @@ export default defineCommand({ const taskId = response.output.task_id; - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}]\n`); process.stderr.write("Note: Video editing typically takes 5-8 minutes. Please be patient.\n"); } // --no-wait or --async: return task ID immediately - if (flags.noWait || config.async) { + if (flags.noWait || settings.async) { emitResult({ task_id: taskId }, format); return; } @@ -189,9 +189,9 @@ export default defineCommand({ // Video editing is compute-intensive; default timeout = 600s (10 min) const pollInterval = flags.pollInterval ?? 15; const pollUrl = ctx.client.url(taskPath(taskId)); - const editTimeout = Math.max(config.timeout, 600); + const editTimeout = Math.max(settings.timeout, 600); - const result = await poll(config, { + const result = await poll(ctx.client, settings, { url: pollUrl, intervalSec: pollInterval, timeoutSec: editTimeout, @@ -214,9 +214,9 @@ export default defineCommand({ // --download: save to file if (flags.download) { const destPath = flags.download; - const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: settings.quiet }); - if (config.quiet) { + if (settings.quiet) { emitBare(destPath); } else { emitResult( @@ -235,10 +235,10 @@ export default defineCommand({ // Default: auto-download to output directory const path = await import("path"); - const destDir = resolveOutputDir(config, { subDir: "videos" }); + const destDir = resolveOutputDir(settings, { subDir: "videos" }); const destPath = path.join(destDir, `${taskId}.mp4`); - await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + await downloadFile(resultVideoUrl, destPath, { quiet: settings.quiet }); emitResult({ task_id: taskId, video_url: resultVideoUrl, saved: destPath }, format); }, diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 3b9de83..132b3e0 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -99,14 +99,14 @@ export default defineCommand({ '--prompt "A cat playing with a ball" --watermark false', ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const prompt = flags.prompt; const model = flags.model || - config.defaultVideoModel || + settings.defaultVideoModel || (flags.image ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v"); - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); const imageUrl = flags.image; @@ -139,17 +139,17 @@ export default defineCommand({ }, }; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } // Submit async task(s) — supports --concurrent for parallel generation - const concurrent = getConcurrency(flags); + const concurrent = getConcurrency(settings); const responses = await runConcurrent( concurrent, - config, + settings, () => ctx.client.requestJson({ path: videoGeneratePath(), @@ -162,12 +162,12 @@ export default defineCommand({ const taskIds = responses.map((r) => r.output.task_id); - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}]\n`); } // --no-wait or --async: return task ID(s) immediately - if (flags.noWait || config.async) { + if (flags.noWait || settings.async) { emitResult(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }, format); return; } @@ -177,10 +177,10 @@ export default defineCommand({ const pollPromises = taskIds.map((taskId) => { const pollUrl = ctx.client.url(taskPath(taskId)); - return poll(config, { + return poll(ctx.client, settings, { url: pollUrl, intervalSec: pollInterval, - timeoutSec: config.timeout, + timeoutSec: settings.timeout, isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED", isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED", getStatus: (d) => (d as DashScopeTaskResponse).output.task_status, @@ -211,9 +211,11 @@ export default defineCommand({ // --download: save to file (first video only for explicit path) if (flags.download) { const destPath = flags.download; - const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { quiet: config.quiet }); + const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { + quiet: settings.quiet, + }); - if (config.quiet) { + if (settings.quiet) { emitBare(destPath); } else { emitResult( @@ -231,7 +233,7 @@ export default defineCommand({ } // Default: auto-download all to output directory - const destDir = resolveOutputDir(config, { subDir: "videos" }); + const destDir = resolveOutputDir(settings, { subDir: "videos" }); // eslint-disable-next-line @typescript-eslint/unbound-method const { join } = await import("path"); @@ -239,7 +241,7 @@ export default defineCommand({ await Promise.all( videos.map(async ({ taskId, videoUrl }) => { const destPath = join(destDir, `${taskId}.mp4`); - await downloadFile(videoUrl, destPath, { quiet: config.quiet }); + await downloadFile(videoUrl, destPath, { quiet: settings.quiet }); saved.push({ task_id: taskId, video_url: videoUrl, saved: destPath }); }), ); diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 7643cd0..18fa291 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -108,7 +108,7 @@ export default defineCommand({ ? "Provide at least one --image or --ref-video." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const prompt = flags.prompt; const images = flags.image || []; @@ -118,7 +118,7 @@ export default defineCommand({ const videoVoices = flags.videoVoice || []; const model = flags.model || "happyhorse-1.0-r2v"; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); // --- Resolve file URLs (auto-upload local files) --- const media: DashScopeVideoRefRequest["input"]["media"] = []; @@ -177,7 +177,7 @@ export default defineCommand({ }, }; - if (config.dryRun) { + if (settings.dryRun) { emitResult({ request: body }, format); return; } @@ -192,7 +192,7 @@ export default defineCommand({ const taskId = response.output.task_id; - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Model: ${model}]\n`); process.stderr.write( `Note: Reference-to-video typically takes 5-10 minutes. Please be patient.\n`, @@ -200,7 +200,7 @@ export default defineCommand({ } // --no-wait or --async: return task ID immediately - if (flags.noWait || config.async) { + if (flags.noWait || settings.async) { emitResult({ task_id: taskId }, format); return; } @@ -208,9 +208,9 @@ export default defineCommand({ // --- Poll until completion --- const pollInterval = flags.pollInterval ?? 15; const pollUrl = ctx.client.url(taskPath(taskId)); - const refTimeout = Math.max(config.timeout, 600); + const refTimeout = Math.max(settings.timeout, 600); - const result = await poll(config, { + const result = await poll(ctx.client, settings, { url: pollUrl, intervalSec: pollInterval, timeoutSec: refTimeout, @@ -233,9 +233,9 @@ export default defineCommand({ // --download: save to file if (flags.download) { const destPath = flags.download; - const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: settings.quiet }); - if (config.quiet) { + if (settings.quiet) { emitBare(destPath); } else { emitResult( @@ -255,10 +255,10 @@ export default defineCommand({ // Default: auto-download to output directory // eslint-disable-next-line @typescript-eslint/unbound-method const { join } = await import("path"); - const destDir = resolveOutputDir(config, { subDir: "videos" }); + const destDir = resolveOutputDir(settings, { subDir: "videos" }); const destPath = join(destDir, `${taskId}.mp4`); - await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet }); + await downloadFile(resultVideoUrl, destPath, { quiet: settings.quiet }); emitResult({ task_id: taskId, video_url: resultVideoUrl, saved: destPath }, format); }, diff --git a/packages/commands/src/commands/video/task-get.ts b/packages/commands/src/commands/video/task-get.ts index 1b1e003..667ae02 100644 --- a/packages/commands/src/commands/video/task-get.ts +++ b/packages/commands/src/commands/video/task-get.ts @@ -18,12 +18,12 @@ export default defineCommand({ "--task-id 3b256896-3e70-xxxx --output json", ], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const taskId = flags.taskId; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ task_id: taskId }, format); return; } @@ -32,7 +32,7 @@ export default defineCommand({ path: taskPath(taskId), }); - if (config.quiet) { + if (settings.quiet) { emitBare(response.output.task_status); return; } diff --git a/packages/commands/src/commands/vision/describe.ts b/packages/commands/src/commands/vision/describe.ts index 643db21..ea577bb 100644 --- a/packages/commands/src/commands/vision/describe.ts +++ b/packages/commands/src/commands/vision/describe.ts @@ -83,7 +83,7 @@ export default defineCommand({ ? "Provide --image or --video." : undefined, async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; let image = flags.image; const videoInputs = flags.video ?? []; const model = flags.model || "qwen3-vl-plus"; @@ -98,9 +98,9 @@ export default defineCommand({ const defaultPrompt = hasVideo ? "Describe the video." : "Describe the image."; const prompt = flags.prompt || defaultPrompt; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult( { request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } }, format, diff --git a/packages/commands/src/commands/workspace/list.ts b/packages/commands/src/commands/workspace/list.ts index d8eaa13..c7ba0e1 100644 --- a/packages/commands/src/commands/workspace/list.ts +++ b/packages/commands/src/commands/workspace/list.ts @@ -88,11 +88,11 @@ export default defineCommand({ }, exampleArgs: ["", "--list 5", "--output json"], async run(ctx) { - const { config, flags } = ctx; + const { settings, flags } = ctx; const limit = Number(flags.list) || 0; - const format = detectOutputFormat(config.output); + const format = detectOutputFormat(settings.output); - if (config.dryRun) { + if (settings.dryRun) { emitResult({ api: LIST_WORKSPACES_API, data: {} }, format); return; } @@ -123,6 +123,6 @@ export default defineCommand({ return; } - printTable(workspaces, config.noColor); + printTable(workspaces, settings.noColor); }, }); diff --git a/packages/commands/tests/boundaries.test.ts b/packages/commands/tests/boundaries.test.ts new file mode 100644 index 0000000..31069b4 --- /dev/null +++ b/packages/commands/tests/boundaries.test.ts @@ -0,0 +1,32 @@ +import { readdirSync, readFileSync, statSync } from "fs"; +import { join } from "path"; +import { expect, test } from "vite-plus/test"; + +// 能力面边界(重构 §6 的 lint 规则):configStore() 仅 config 命令族、authStore() +// 仅 auth 命令族可用;业务命令只依赖 settings/flags/client。 + +const ROOT = join(import.meta.dirname, "../src/commands"); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) out.push(...walk(p)); + else if (p.endsWith(".ts")) out.push(p); + } + return out; +} + +test("configStore() 仅在 commands/config/** 使用", () => { + for (const file of walk(ROOT)) { + if (file.includes("/config/")) continue; + expect(readFileSync(file, "utf8").includes("configStore("), file).toBe(false); + } +}); + +test("authStore() 仅在 commands/auth/** 使用", () => { + for (const file of walk(ROOT)) { + if (file.includes("/auth/")) continue; + expect(readFileSync(file, "utf8").includes("authStore("), file).toBe(false); + } +}); diff --git a/packages/core/src/advisor/cache.ts b/packages/core/src/advisor/cache.ts index 08acc76..66db7e3 100644 --- a/packages/core/src/advisor/cache.ts +++ b/packages/core/src/advisor/cache.ts @@ -1,4 +1,4 @@ -import type { Config } from "../config/schema.ts"; +import type { Settings } from "../config/schema.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { ApiSource } from "./sources/api.ts"; @@ -11,12 +11,12 @@ export interface GetModelsOptions { } export async function getModels( - config: Config, + settings: Settings, options?: GetModelsOptions, ): Promise { const sources: ModelSource[] = [ new CatalogSource({ onPrepareStart: options?.onPrepareStart }), - new ApiSource(config), + new ApiSource(settings), ]; for (const source of sources) { diff --git a/packages/core/src/advisor/embedding.ts b/packages/core/src/advisor/embedding.ts index d8fc620..34fbbf9 100644 --- a/packages/core/src/advisor/embedding.ts +++ b/packages/core/src/advisor/embedding.ts @@ -1,8 +1,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getConfigDir } from "../config/paths.ts"; -import type { Config } from "../config/schema.ts"; -import { requestJson } from "../client/http.ts"; +import type { Client } from "../client/client.ts"; import type { ModelProfile } from "./types.ts"; const EMBEDDING_MODEL = "text-embedding-v4"; @@ -41,8 +40,8 @@ export function loadModelEmbeddings(): ModelEmbedding[] | null { } } -export async function embedQuery(config: Config, text: string): Promise { - const url = `${config.baseUrl}/compatible-mode/v1/embeddings`; +export async function embedQuery(client: Client, text: string): Promise { + const url = "/compatible-mode/v1/embeddings"; const body = { model: EMBEDDING_MODEL, input: [text], @@ -50,15 +49,15 @@ export async function embedQuery(config: Config, text: string): Promise(config, { url, method: "POST", body, timeout: 10000 }); + }>({ path: url, method: "POST", body, timeout: 10000 }); return response.data[0].embedding; } -async function embedBatch(config: Config, texts: string[]): Promise { - const url = `${config.baseUrl}/compatible-mode/v1/embeddings`; +async function embedBatch(client: Client, texts: string[]): Promise { + const url = "/compatible-mode/v1/embeddings"; const body = { model: EMBEDDING_MODEL, input: texts, @@ -66,9 +65,9 @@ async function embedBatch(config: Config, texts: string[]): Promise encoding_format: "float", }; - const response = await requestJson<{ + const response = await client.requestJson<{ data: { index: number; embedding: number[] }[]; - }>(config, { url, method: "POST", body, timeout: 30000 }); + }>({ path: url, method: "POST", body, timeout: 30000 }); return response.data .sort((left, right) => left.index - right.index) @@ -147,7 +146,7 @@ function buildModelText(model: ModelProfile, descriptions: Map): } export async function buildAndCacheEmbeddings( - config: Config, + client: Client, models: ModelProfile[], ): Promise { const descriptions = loadGroupDescriptions(); @@ -156,7 +155,7 @@ export async function buildAndCacheEmbeddings( const allVectors: number[][] = []; for (let batchStart = 0; batchStart < texts.length; batchStart += BATCH_SIZE) { const batch = texts.slice(batchStart, batchStart + BATCH_SIZE); - const vectors = await embedBatch(config, batch); + const vectors = await embedBatch(client, batch); allVectors.push(...vectors); } diff --git a/packages/core/src/advisor/intent.ts b/packages/core/src/advisor/intent.ts index e3af6cf..bb29a8a 100644 --- a/packages/core/src/advisor/intent.ts +++ b/packages/core/src/advisor/intent.ts @@ -1,14 +1,13 @@ -import { requestJson } from "../client/http.ts"; import { chatPath } from "../client/endpoints.ts"; -import type { Config } from "../config/schema.ts"; +import type { Client } from "../client/client.ts"; import type { ChatResponse } from "../types/api.ts"; import { Complexities } from "./types.ts"; import type { IntentProfile } from "./types.ts"; import { INTENT_MODEL, INTENT_SYSTEM_PROMPT } from "./constants/prompts.ts"; import { DEFAULT_INTENT } from "./constants/defaults.ts"; -export async function analyzeIntent(config: Config, input: string): Promise { - const url = config.baseUrl + chatPath(); +export async function analyzeIntent(client: Client, input: string): Promise { + const url = chatPath(); const body = { model: INTENT_MODEL, @@ -21,8 +20,8 @@ export async function analyzeIntent(config: Config, input: string): Promise(config, { - url, + const response = await client.requestJson({ + path: url, method: "POST", body, timeout: 5000, diff --git a/packages/core/src/advisor/recall-semantic.ts b/packages/core/src/advisor/recall-semantic.ts index ffeebc8..1f1103b 100644 --- a/packages/core/src/advisor/recall-semantic.ts +++ b/packages/core/src/advisor/recall-semantic.ts @@ -1,4 +1,4 @@ -import type { Config } from "../config/schema.ts"; +import type { Client } from "../client/client.ts"; import type { IntentProfile, IntentSegment, ModelPreference, ModelProfile } from "./types.ts"; import { Complexities } from "./types.ts"; import { @@ -175,7 +175,7 @@ function recallAlternative( } export async function recallSemantic( - config: Config, + client: Client, models: ModelProfile[], query: string, topK: number, @@ -184,11 +184,11 @@ export async function recallSemantic( let embeddings = getEmbeddings(); if (!embeddings) { - embeddings = await buildAndCacheEmbeddings(config, models); + embeddings = await buildAndCacheEmbeddings(client, models); cachedEmbeddings = embeddings; } - const queryVector = await embedQuery(config, query); + const queryVector = await embedQuery(client, query); const modelMap = new Map(models.map((profile) => [profile.model, profile])); const preference = intent?.modelPreference; const excludes = preference?.excludes ?? []; diff --git a/packages/core/src/advisor/recommend.ts b/packages/core/src/advisor/recommend.ts index 43a608f..5b95353 100644 --- a/packages/core/src/advisor/recommend.ts +++ b/packages/core/src/advisor/recommend.ts @@ -1,7 +1,6 @@ import { chatPath } from "../client/endpoints.ts"; -import { request, requestJson } from "../client/http.ts"; import { parseSSE } from "../client/stream.ts"; -import type { Config } from "../config/schema.ts"; +import type { Client } from "../client/client.ts"; import type { ChatResponse, StreamChunk } from "../types/api.ts"; import { ALTERNATIVE_SYSTEM_PROMPT, @@ -187,7 +186,7 @@ function validatePipelineCompatibility( } export async function rankModels( - config: Config, + client: Client, candidates: ScoredCandidate[], intent: IntentProfile, userInput: string, @@ -238,12 +237,12 @@ export async function rankModels( body.enable_thinking = true; } - const url = config.baseUrl + chatPath(); + const url = chatPath(); let content: string; if (useThinkingModel) { - const res = await request(config, { - url, + const res = await client.request({ + path: url, method: "POST", body, stream: true, @@ -274,8 +273,8 @@ export async function rankModels( } content = accumulated || "{}"; } else { - const response = await requestJson(config, { - url, + const response = await client.requestJson({ + path: url, method: "POST", body, }); diff --git a/packages/core/src/advisor/sources/api.ts b/packages/core/src/advisor/sources/api.ts index 8aa124e..17e7c69 100644 --- a/packages/core/src/advisor/sources/api.ts +++ b/packages/core/src/advisor/sources/api.ts @@ -1,5 +1,5 @@ -import type { Config } from "../../config/schema.ts"; -import { callConsoleGateway } from "../../console/gateway.ts"; +import type { Settings } from "../../config/schema.ts"; +import { callConsoleGateway, effectiveConsoleGatewayConfig } from "../../console/gateway.ts"; import { fetchModelList } from "../../console/models.ts"; import type { ModelProfile } from "../types.ts"; import type { ModelSource } from "./types.ts"; @@ -33,7 +33,7 @@ function toModelProfile(item: Record): ModelProfile | null { export class ApiSource implements ModelSource { readonly name = "api"; - constructor(private config: Config) {} + constructor(private settings: Settings) {} available(): boolean { return true; @@ -41,8 +41,13 @@ export class ApiSource implements ModelSource { async load(): Promise { // Public model catalog — no console token (advisor runs unauthenticated). + const eff = effectiveConsoleGatewayConfig(this.settings); const call = (api: string, data: Record) => - callConsoleGateway(this.config, "", { api, data }); + callConsoleGateway( + { region: eff.consoleRegion, site: eff.consoleSite, switchAgent: eff.consoleSwitchAgent }, + this.settings.timeout, + { api, data }, + ); const first = await fetchModelList(call, { pageNo: 1, pageSize: PAGE_SIZE }); const allRaw = [...first.models]; diff --git a/packages/core/src/auth/credentials.ts b/packages/core/src/auth/credentials.ts deleted file mode 100644 index b7c57ad..0000000 --- a/packages/core/src/auth/credentials.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { readFileSync, writeFileSync, renameSync, existsSync } from "fs"; -import { getConfigPath, ensureConfigDir } from "../config/index.ts"; - -export function loadApiKeyFromConfig(): string | null { - const path = getConfigPath(); - if (!existsSync(path)) return null; - - try { - const raw = readFileSync(path, "utf-8"); - const data = JSON.parse(raw) as Record; - if (typeof data.api_key === "string" && data.api_key.length > 0) { - return data.api_key; - } - return null; - } catch { - return null; - } -} - -export async function saveApiKeyToConfig(apiKey: string): Promise { - await ensureConfigDir(); - const path = getConfigPath(); - let existing: Record = {}; - try { - existing = JSON.parse(readFileSync(path, "utf-8")); - } catch { - /* ignore */ - } - existing.api_key = apiKey; - const tmp = path + ".tmp"; - writeFileSync(tmp, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 }); - renameSync(tmp, path); -} - -export async function clearApiKey(): Promise { - const path = getConfigPath(); - if (!existsSync(path)) return; - try { - const existing = JSON.parse(readFileSync(path, "utf-8")); - delete existing.api_key; - delete existing.access_token; - const tmp = path + ".tmp"; - writeFileSync(tmp, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 }); - renameSync(tmp, path); - } catch { - /* ignore */ - } -} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 5759fc4..3f199e5 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,3 +1,8 @@ -export { clearApiKey, loadApiKeyFromConfig, saveApiKeyToConfig } from "./credentials.ts"; -export { resolveApiKeyCredential, resolveConsoleCredential, describeAuth } from "./resolver.ts"; +export { + resolveApiKey, + resolveConsole, + describeAuthState, + resolveModelBaseUrl, +} from "./resolver.ts"; +export { makeAuthStore, type AuthStore, type AuthPersistPatch } from "./store.ts"; export type { ApiKeyCredential, ConsoleCredential, AuthState, CredentialSource } from "./types.ts"; diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index b4e3712..b35095f 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -1,20 +1,27 @@ -import type { Config } from "../config/schema.ts"; +import { REGIONS } from "../config/schema.ts"; +import type { ResolutionSources } from "../config/loader.ts"; import type { ApiKeyCredential, ConsoleCredential, AuthState } from "./types.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; // Resolve the credential for a command's declared domain (model = api-key, -// console = access-token), by priority, or throw. Read only from `config`. +// console = access-token), by priority, or throw. Read only from sources. + +/** Model-domain baseUrl(flag > env > file > cn)——无需 key 也可解析;login 验证等用。 */ +export function resolveModelBaseUrl(s: ResolutionSources): string { + return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || REGIONS.cn; +} /** - * Model-domain credential — always an API key. Priority: `--api-key` flag > - * `DASHSCOPE_API_KEY` env > config.json `api_key`. No access tokens here. + * Model-domain credential from sources. Priority: `--api-key` flag > + * `DASHSCOPE_API_KEY` env > config.json `api_key`. baseUrl: flag > env > file > cn. */ -export async function resolveApiKeyCredential(config: Config): Promise { - const baseUrl = config.baseUrl; - if (config.apiKey) return { token: config.apiKey, baseUrl, source: "flag" }; - if (config.apiKeyEnv) return { token: config.apiKeyEnv, baseUrl, source: "env" }; - if (config.fileApiKey) return { token: config.fileApiKey, baseUrl, source: "config" }; +export function resolveApiKey(s: ResolutionSources): ApiKeyCredential { + const baseUrl = resolveModelBaseUrl(s); + if (s.flags.apiKey) return { token: s.flags.apiKey, baseUrl, source: "flag" }; + const envKey = s.env.DASHSCOPE_API_KEY?.trim(); + if (envKey) return { token: envKey, baseUrl, source: "env" }; + if (s.file.api_key) return { token: s.file.api_key, baseUrl, source: "config" }; throw new BailianError( "No API key found.", ExitCode.AUTH, @@ -22,34 +29,35 @@ export async function resolveApiKeyCredential(config: Config): Promise { - if (config.fileAccessToken) { - return { - token: config.fileAccessToken, - region: config.consoleRegion ?? "cn-beijing", - site: config.consoleSite ?? "domestic", - switchAgent: config.consoleSwitchAgent, - source: "config", - }; +/** Console-domain credential from sources — access token + 连接目标(flag > file > 默认)。 */ +export function resolveConsole(s: ResolutionSources): ConsoleCredential { + const token = s.file.access_token?.trim(); + if (!token) { + throw new BailianError( + "No console access token found.", + ExitCode.AUTH, + "Run `bl auth login --console`.", + ); } - throw new BailianError( - "No console access token found.", - ExitCode.AUTH, - "Run `bl auth login --console`.", - ); + return { + token, + region: s.flags.consoleRegion || s.file.console_region || "cn-beijing", + site: (s.flags.consoleSite as ConsoleCredential["site"]) || s.file.console_site || "domestic", + switchAgent: s.flags.consoleSwitchAgent || s.file.console_switch_agent || undefined, + source: "config", + }; } -/** Full auth snapshot for `bl auth status` — what would resolve per domain (or undefined). */ -export async function describeAuth(config: Config): Promise { +/** Full auth snapshot from sources — what would resolve per domain (or undefined). */ +export function describeAuthState(s: ResolutionSources): AuthState { const state: AuthState = {}; try { - state.apiKey = await resolveApiKeyCredential(config); + state.apiKey = resolveApiKey(s); } catch { /* no model credential */ } try { - state.console = await resolveConsoleCredential(config); + state.console = resolveConsole(s); } catch { /* no console credential */ } diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts new file mode 100644 index 0000000..5f11a6d --- /dev/null +++ b/packages/core/src/auth/store.ts @@ -0,0 +1,64 @@ +import type { ConfigFile } from "../config/schema.ts"; +import type { ResolutionSources } from "../config/loader.ts"; +import { readConfigFile, writeConfigFile } from "../config/loader.ts"; +import type { AuthState } from "./types.ts"; +import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts"; + +/** 登录允许落盘的键:凭证本体 + 登录回调携带的连接/作用域字段。 */ +export type AuthPersistPatch = Pick< + ConfigFile, + | "api_key" + | "access_token" + | "base_url" + | "console_site" + | "console_region" + | "console_switch_agent" + | "workspace_id" +>; + +/** + * auth 命令族的凭证能力面(lint 限定 commands/auth/** 使用)。 + * 登录产生的全部落盘走 login,不放宽 configStore 的边界。 + */ +export interface AuthStore { + /** 各域"将会解析出"的凭证快照(auth status 用)。 */ + describe(): AuthState; + /** 磁盘上当前是否存有各域凭证(区别于 describe:只看 file,不含 flag/env 源)。 */ + stored(): { apiKey: boolean; console: boolean }; + /** model 域 baseUrl 链(flag > env > file > 默认);验证 API key 等无凭证场景用。 */ + resolveBaseUrl(): string; + /** 登录落盘:合并写入,undefined 键忽略。 */ + login(patch: AuthPersistPatch): Promise; + /** 清凭证:console 只删 access_token;all 删 api_key + access_token。返回是否有变更。 */ + logout(scope: "console" | "all"): Promise; +} + +export function makeAuthStore(sources: ResolutionSources): AuthStore { + return { + describe: () => describeAuthState(sources), + stored() { + const file = readConfigFile(); + return { apiKey: !!file.api_key, console: !!file.access_token }; + }, + resolveBaseUrl: () => resolveModelBaseUrl(sources), + async login(patch) { + const existing = readConfigFile() as Record; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) existing[key] = value; + } + await writeConfigFile(existing); + }, + async logout(scope) { + const existing = readConfigFile() as Record; + const had = + scope === "console" + ? existing.access_token !== undefined + : existing.access_token !== undefined || existing.api_key !== undefined; + if (!had) return false; + delete existing.access_token; + if (scope === "all") delete existing.api_key; + await writeConfigFile(existing); + return true; + }, + }; +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 2a01337..395413d 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -1,13 +1,23 @@ -import type { Config } from "../config/schema.ts"; +import type { Identity, Settings } from "../config/schema.ts"; import type { ApiKeyCredential, ConsoleCredential } from "../auth/types.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; -import { request, requestJson, type RequestOpts } from "./http.ts"; +import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts"; import { resolveFileUrl } from "../files/upload.ts"; import { McpClient } from "./mcp.ts"; import { callConsoleGateway } from "../console/gateway.ts"; -/** Like {@link RequestOpts} but with a `path` (Client prepends the credential's baseUrl). */ +/** Client 的结构化依赖:身份 + 有效配置 + 各域凭证(按命令的 auth 注入)。 */ +export interface ClientDeps { + identity: Identity; + settings: Settings; + /** Model 域 base URL(凭证无关链解析,resolveModelBaseUrl;有 apiCred 时两者一致)。 */ + baseUrl: string; + apiCred?: ApiKeyCredential; + consoleCred?: ConsoleCredential; +} + +/** Like {@link RequestOpts} but with a `path` (credential baseUrl prepended) or an absolute URL. */ export interface ClientRequestOpts extends Omit { path: string; } @@ -20,22 +30,22 @@ export interface ClientRequestOpts extends Omit { * throws. */ export class Client { - constructor( - private readonly config: Config, - private readonly apiCred?: ApiKeyCredential, - private readonly consoleCred?: ConsoleCredential, - ) {} + constructor(private readonly deps: ClientDeps) {} + + private get http(): HttpDeps { + return { identity: this.deps.identity, settings: this.deps.settings }; + } private requireApi(): ApiKeyCredential { - if (!this.apiCred) { + if (!this.deps.apiCred) { throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH); } - return this.apiCred; + return this.deps.apiCred; } /** Model-domain base URL. Readable without a key (e.g. dry-run preview); real requests still need one. */ get baseUrl(): string { - return this.apiCred?.baseUrl ?? this.config.baseUrl; + return this.deps.apiCred?.baseUrl ?? this.deps.baseUrl; } /** Full URL for a model-domain {@link path}; build request/display URLs only through this. */ @@ -47,18 +57,17 @@ export class Client { const cred = this.requireApi(); return { ...rest, - url: cred.baseUrl + path, + url: /^https?:\/\//.test(path) ? path : cred.baseUrl + path, headers: { ...rest.headers, Authorization: `Bearer ${cred.token}` }, - noAuth: true, }; } request(opts: ClientRequestOpts): Promise { - return request(this.config, this.toOpts(opts)); + return request(this.http, this.toOpts(opts)); } requestJson(opts: ClientRequestOpts): Promise { - return requestJson(this.config, this.toOpts(opts)); + return requestJson(this.http, this.toOpts(opts)); } /** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */ @@ -69,14 +78,17 @@ export class Client { /** Open an MCP client. Accepts a path (prepended with the model baseUrl) or an absolute URL. */ mcp(pathOrUrl: string): McpClient { const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : this.requireApi().baseUrl + pathOrUrl; - return new McpClient(this.config, url, this.apiCred?.token); + return new McpClient(this.http, url, this.deps.apiCred?.token); } console(api: string, data: Record): Promise { - if (!this.consoleCred) { + if (!this.deps.consoleCred) { throw new BailianError("This command needs a console access token.", ExitCode.AUTH); } - // Pass only `api` + `data`; region / site / switchAgent come from config. - return callConsoleGateway(this.config, this.consoleCred.token, { api, data }) as Promise; + // region / site / switchAgent 已解析在 consoleCred 里,gateway 不再回读 config。 + return callConsoleGateway(this.deps.consoleCred, this.deps.settings.timeout, { + api, + data, + }) as Promise; } } diff --git a/packages/core/src/client/http.ts b/packages/core/src/client/http.ts index 0c3b21b..cd7110a 100644 --- a/packages/core/src/client/http.ts +++ b/packages/core/src/client/http.ts @@ -1,11 +1,15 @@ -import type { Config } from "../config/schema.ts"; +import type { Identity, Settings } from "../config/schema.ts"; import type { ApiErrorBody } from "../errors/api.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; -import { resolveApiKeyCredential } from "../auth/resolver.ts"; import { mapApiError } from "../errors/api.ts"; -import { maskToken } from "../utils/token.ts"; -import { SOURCE_CONFIG, trackingHeaders } from "./headers.ts"; +import { trackingHeaders } from "./headers.ts"; + +/** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */ +export interface HttpDeps { + identity: Identity; + settings: Settings; +} export interface RequestOpts { url: string; @@ -14,7 +18,6 @@ export interface RequestOpts { headers?: Record; timeout?: number; stream?: boolean; - noAuth?: boolean; async?: boolean; // Add X-DashScope-Async: enable header signal?: AbortSignal; } @@ -30,13 +33,11 @@ function bodyReferencesOssUrl(body: unknown): boolean { return JSON.stringify(body).includes("oss://"); } -export async function request(config: Config, opts: RequestOpts): Promise { +export async function request(deps: HttpDeps, opts: RequestOpts): Promise { const isFormData = typeof FormData !== "undefined" && opts.body instanceof FormData; - const clientName = config.clientName ?? "bailian-cli-core"; - const version = config.clientVersion ?? "0.0.0-dev"; const headers: Record = { - "User-Agent": `${clientName}/${version}`, + "User-Agent": `${deps.identity.clientName}/${deps.identity.version}`, ...trackingHeaders(), ...opts.headers, }; @@ -53,18 +54,7 @@ export async function request(config: Config, opts: RequestOpts): Promise ${opts.method ?? "GET"} ${opts.url}`); - console.error(`> Auth: ${maskToken(credential.token)}`); - console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`); - } - } - - const timeoutMs = (opts.timeout ?? config.timeout) * 1000; + const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000; const requestSignal = createRequestSignal(timeoutMs, opts.signal); const res = await fetch(opts.url, { @@ -78,7 +68,7 @@ export async function request(config: Config, opts: RequestOpts): Promise(config: Config, opts: RequestOpts): Promise { - const res = await request(config, opts); +export async function requestJson(deps: HttpDeps, opts: RequestOpts): Promise { + const res = await request(deps, opts); let data: T & { code?: string; message?: string; request_id?: string }; try { data = (await res.json()) as T & { code?: string; message?: string; request_id?: string }; diff --git a/packages/core/src/client/mcp.ts b/packages/core/src/client/mcp.ts index 5d29f8e..47bd3ec 100644 --- a/packages/core/src/client/mcp.ts +++ b/packages/core/src/client/mcp.ts @@ -6,15 +6,14 @@ * * Protocol flow: initialize → tools/list → tools/call * - * Auth: always sends `Authorization: Bearer ` resolved via - * `resolveApiKeyCredential`. Bailian MCPs all accept this; non-Bailian endpoints + * Auth: always sends `Authorization: Bearer ` injected by the + * caller (Client.mcp). Bailian MCPs all accept this; non-Bailian endpoints * are out of scope for this client. */ -import type { Config } from "../config/schema.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; -import { resolveApiKeyCredential } from "../auth/resolver.ts"; +import type { HttpDeps } from "./http.ts"; import { trackingHeaders } from "./headers.ts"; // ---- JSON-RPC 2.0 Types ---- @@ -68,11 +67,11 @@ export class McpClient { private url: string; private sessionId: string | undefined; private nextId = 1; - private config: Config; + private deps: HttpDeps; private authToken: string | undefined; - constructor(config: Config, url: string, authToken?: string) { - this.config = config; + constructor(deps: HttpDeps, url: string, authToken?: string) { + this.deps = deps; this.url = url; this.authToken = authToken; } @@ -80,19 +79,19 @@ export class McpClient { /** Initialize the MCP session. Must be called before any other method. */ async initialize(): Promise { if (!this.authToken) { - this.authToken = (await resolveApiKeyCredential(this.config)).token; + throw new BailianError("This command needs a model-domain API key.", ExitCode.AUTH); } const result = await this.rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { - name: this.config.clientName ?? "bailian-cli-core", - version: this.config.clientVersion ?? "0.0.0-dev", + name: this.deps.identity.clientName, + version: this.deps.identity.version, }, }); - if (this.config.verbose) { + if (this.deps.settings.verbose) { console.error(`[MCP] Session initialized: ${this.sessionId ?? "no session"}`); console.error(`[MCP] Server: ${JSON.stringify(result)}`); } @@ -148,7 +147,7 @@ export class McpClient { const headers: Record = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", - "User-Agent": `${this.config.clientName ?? "bailian-cli-core"}/${this.config.clientVersion ?? "0.0.0-dev"}`, + "User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`, ...trackingHeaders(), }; @@ -160,12 +159,12 @@ export class McpClient { headers["Mcp-Session-Id"] = this.sessionId; } - if (this.config.verbose) { + if (this.deps.settings.verbose) { console.error(`> POST ${this.url}`); console.error(`> Method: ${(body as { method?: string }).method}`); } - const timeoutMs = this.config.timeout * 1000; + const timeoutMs = this.deps.settings.timeout * 1000; const res = await fetch(this.url, { method: "POST", headers, @@ -173,7 +172,7 @@ export class McpClient { signal: AbortSignal.timeout(timeoutMs), }); - if (this.config.verbose) { + if (this.deps.settings.verbose) { console.error(`< ${res.status} ${res.statusText}`); } diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 6ba0f83..6825de9 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,4 +1,6 @@ -export type { Config, ConfigFile, Region } from "./schema.ts"; +export type { ConfigFile, Region, Identity, Settings } from "./schema.ts"; export { BAILIAN_HOST, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts"; -export { loadConfig, readConfigFile, writeConfigFile } from "./loader.ts"; +export { readConfigFile, writeConfigFile } from "./loader.ts"; +export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts"; +export { makeConfigStore, type ConfigStore } from "./store.ts"; export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 77a1391..29fa5a7 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, renameSync, existsSync } from "fs"; -import { parseConfigFile, REGIONS, type Config, type ConfigFile } from "./schema.ts"; +import { parseConfigFile, type ConfigFile, type Settings } from "./schema.ts"; import { ensureConfigDir, getConfigPath } from "./paths.ts"; -import { detectOutputFormat, type OutputFormat } from "../output/formatter.ts"; +import { detectOutputFormat } from "../output/formatter.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import type { GlobalFlags } from "../types/command.ts"; @@ -28,23 +28,28 @@ export async function writeConfigFile(data: Record): Promise; + file: ConfigFile; + env: NodeJS.ProcessEnv; +} - const apiKey = flags.apiKey || undefined; - const apiKeyEnv = process.env.DASHSCOPE_API_KEY?.trim() || undefined; - const fileApiKey = file.api_key; - const fileAccessToken = file.access_token?.trim() || undefined; +export function buildSources(globalFlags: Partial): ResolutionSources { + return { flags: globalFlags, file: readConfigFile(), env: process.env }; +} - const baseUrl = flags.baseUrl || file.base_url || process.env.DASHSCOPE_BASE_URL || REGIONS.cn; +/** + * 纯解析 sources → Settings(命令唯一会读的配置面)。不含身份、baseUrl、鉴权。 + * 各字段链序 flag > env > file > 默认;锁定表 tests/config-priority.test.ts。 + */ +export function buildSettings(s: ResolutionSources): Settings { + const { flags, file, env } = s; - const output: OutputFormat = detectOutputFormat( - flags.output || process.env.DASHSCOPE_OUTPUT || file.output, - ); - - const envTimeout = process.env.DASHSCOPE_TIMEOUT - ? Number(process.env.DASHSCOPE_TIMEOUT) - : undefined; + const envTimeout = env.DASHSCOPE_TIMEOUT ? Number(env.DASHSCOPE_TIMEOUT) : undefined; const validEnvTimeout = envTimeout !== undefined && Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout @@ -55,32 +60,27 @@ export function loadConfig(flags: GlobalFlags): Config { } return { - apiKey, - apiKeyEnv, - fileAccessToken, - fileApiKey, configPath: getConfigPath(), - baseUrl, - output, + output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output), outputDir: file.output_dir || undefined, timeout, + concurrent: flags.concurrent, defaultTextModel: file.default_text_model, defaultVideoModel: file.default_video_model, defaultImageModel: file.default_image_model, defaultSpeechModel: file.default_speech_model, defaultOmniModel: file.default_omni_model, - workspaceId: process.env.BAILIAN_WORKSPACE_ID || file.workspace_id || undefined, - consoleSite: (flags.consoleSite as Config["consoleSite"]) || file.console_site || undefined, - consoleRegion: (flags.consoleRegion as string) || file.console_region || undefined, - consoleSwitchAgent: - (flags.consoleSwitchAgent as number) || file.console_switch_agent || undefined, - verbose: flags.verbose || process.env.DASHSCOPE_VERBOSE === "1", + workspaceId: env.BAILIAN_WORKSPACE_ID || file.workspace_id || undefined, + consoleRegion: flags.consoleRegion || file.console_region || undefined, + consoleSite: (flags.consoleSite as Settings["consoleSite"]) || file.console_site || undefined, + consoleSwitchAgent: flags.consoleSwitchAgent || file.console_switch_agent || undefined, + verbose: flags.verbose || env.DASHSCOPE_VERBOSE === "1", quiet: flags.quiet || false, - noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY, + noColor: flags.noColor || env.NO_COLOR !== undefined || !process.stdout.isTTY, yes: flags.yes || false, dryRun: flags.dryRun || false, nonInteractive: flags.nonInteractive || false, async: flags.async || false, - telemetry: process.env.DO_NOT_TRACK === "1" ? false : (file.telemetry ?? true), + telemetry: env.DO_NOT_TRACK === "1" ? false : (file.telemetry ?? true), }; } diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index f6e2ce2..2f55ad5 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -91,33 +91,37 @@ export function parseConfigFile(raw: unknown): ConfigFile { return out; } -export interface Config { - clientName?: string; - clientVersion?: string; - /** Product binary name (e.g. "bl", "rag"), injected by createCli for command-facing output. */ - binName?: string; - /** npm package name for self-update (e.g. "bailian-cli", "bailian-cli-rag"), injected by createCli. */ - npmPackage?: string; - /** `--api-key` flag (highest priority for the model domain). */ - apiKey?: string; - /** `DASHSCOPE_API_KEY` env (model domain). */ - apiKeyEnv?: string; - /** `access_token` in config file (console login). */ - fileAccessToken?: string; - fileApiKey?: string; +/** 静态产品身份,createCli 注入一次(bl/rag 各异,故注入而非模块常量)。 */ +export interface Identity { + /** Product binary name, e.g. "bl", "rag". */ + binName: string; + version: string; + /** npm package name for self-update, e.g. "bailian-cli". */ + npmPackage: string; + /** User-Agent / telemetry client name. */ + clientName: string; +} + +/** + * 命令唯一会读的配置面(flag/env/file 解析后的有效值)。 + * 不含身份(Identity)、不含秘密(credential);console 三元组为 dry-run 展示保留, + * 真实调用走 ConsoleCredential(同链解析,受控重叠)。 + */ +export interface Settings { configPath?: string; - baseUrl: string; output: "text" | "json"; outputDir?: string; timeout: number; + /** `--concurrent`,仅 flag 源。 */ + concurrent?: number; defaultTextModel?: string; defaultVideoModel?: string; defaultImageModel?: string; defaultSpeechModel?: string; defaultOmniModel?: string; workspaceId?: string; - consoleSite?: "domestic" | "international"; consoleRegion?: string; + consoleSite?: "domestic" | "international"; consoleSwitchAgent?: number; verbose: boolean; quiet: boolean; diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts new file mode 100644 index 0000000..8ce9833 --- /dev/null +++ b/packages/core/src/config/store.ts @@ -0,0 +1,38 @@ +import type { ConfigFile } from "./schema.ts"; +import { readConfigFile, writeConfigFile } from "./loader.ts"; +import { getConfigPath } from "./paths.ts"; + +/** + * config 命令族的持久化能力面(lint 限定 commands/config/** 使用)。 + * 读写都直达磁盘(非 dispatch 时的快照),与现有 config set/show 的行为一致。 + */ +export interface ConfigStore { + read(): ConfigFile; + /** 合并写入;patch 里值为 undefined 的键会被删除。 */ + write(patch: Partial): Promise; + /** 删除指定键。 */ + unset(keys: (keyof ConfigFile)[]): Promise; + path: string; +} + +export function makeConfigStore(): ConfigStore { + return { + read: () => readConfigFile(), + async write(patch) { + const existing = readConfigFile() as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) delete existing[key]; + else existing[key] = value; + } + await writeConfigFile(existing); + }, + async unset(keys) { + const existing = readConfigFile() as Record; + for (const key of keys) delete existing[key]; + await writeConfigFile(existing); + }, + get path() { + return getConfigPath(); + }, + }; +} diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index 30700ed..a7a5fad 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -1,4 +1,4 @@ -import type { Config } from "../config/schema.ts"; +import type { Settings } from "../config/schema.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; @@ -35,8 +35,13 @@ function resolveGateway(region: string, site: ConsoleSite): ConsoleGatewayInfo { return REGION_GATEWAYS[region]?.[site] ?? REGION_GATEWAYS["cn-beijing"]![site]; } -/** Resolved console gateway settings (same defaults as {@link callConsoleGateway}). */ -export function effectiveConsoleGatewayConfig(config: Config): { +/** + * Resolved console gateway settings (same defaults as {@link callConsoleGateway}). + * 参数只需 settings 的 console 三元组;dry-run 展示分支直接传 settings。 + */ +export function effectiveConsoleGatewayConfig( + config: Pick, +): { consoleRegion: string; consoleSite: ConsoleSite; consoleSwitchAgent?: number; @@ -53,12 +58,6 @@ export interface ConsoleGatewayRequest { /** Console API name, e.g. zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota */ api: string; data: Record; - /** Console region (e.g. cn-beijing, ap-southeast-1). Falls back to config.consoleRegion, then "cn-beijing". */ - region?: string; - /** Console site. Falls back to config.consoleSite, then "domestic". */ - site?: ConsoleSite; - /** Switch-agent UID for delegated access. Falls back to config.consoleSwitchAgent. */ - switchAgent?: number; } function buildGatewayParams( @@ -87,37 +86,34 @@ function buildGatewayParams( /** * Invoke a Bailian **console** OpenAPI via the CLI gateway (`/cli/api.json`). - * `token` is the console `access_token` (from `bl auth login --console`); when - * omitted the request is sent without an Authorization header, which works for - * public console APIs that don't require a login session. - * - * Gateway URL and action are resolved from `region + site` via {@link REGION_GATEWAYS}. - * Each parameter falls back to the corresponding config value, then to a hardcoded default. + * 目标(region/site/switchAgent)与 token 均由调用方解析后传入:Client.console 传 + * ConsoleCredential;公共目录类 API(如 advisor 的模型目录)可不带 token 匿名调用。 */ +export interface ConsoleGatewayTarget { + region: string; + site: ConsoleSite; + switchAgent?: number; + token?: string; +} + export async function callConsoleGateway( - config: Config, - token: string | undefined, + target: ConsoleGatewayTarget, + timeoutSec: number, { api, data }: ConsoleGatewayRequest, ): Promise { - const { - consoleRegion: effectiveRegion, - consoleSite: effectiveSite, - consoleSwitchAgent: effectiveSwitchAgent, - } = effectiveConsoleGatewayConfig(config); - - const resolved = resolveGateway(effectiveRegion, effectiveSite); + const resolved = resolveGateway(target.region, target.site); const gatewayBase = `https://${resolved.csGateway}`; const action = resolved.action; - const params = buildGatewayParams(api, data, effectiveSwitchAgent); - const body = new URLSearchParams({ params, region: effectiveRegion }); - const timeoutMs = config.timeout * 1000; + const params = buildGatewayParams(api, data, target.switchAgent); + const body = new URLSearchParams({ params, region: target.region }); + const timeoutMs = timeoutSec * 1000; const headers: Record = { Accept: "*/*", "Content-Type": "application/x-www-form-urlencoded", }; - if (token) headers.Authorization = `Bearer ${token}`; + if (target.token) headers.Authorization = `Bearer ${target.token}`; const res = await fetch( `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`, diff --git a/packages/core/src/telemetry/tracker.ts b/packages/core/src/telemetry/tracker.ts index 912e7e7..1245703 100644 --- a/packages/core/src/telemetry/tracker.ts +++ b/packages/core/src/telemetry/tracker.ts @@ -1,5 +1,4 @@ -import type { Config } from "../config/schema.ts"; -import type { GlobalFlags } from "../types/command.ts"; +import type { Identity, Settings } from "../config/schema.ts"; import { BailianError } from "../errors/base.ts"; import { createTrackingEvent } from "./event.ts"; import { localSink, remoteSink } from "./sink.ts"; @@ -75,7 +74,7 @@ const PARAM_ALLOWLIST = new Set([ "diarization", ]); -function extractParams(flags: GlobalFlags): Record { +function extractParams(flags: Record): Record { const params: Record = {}; for (const [key, value] of Object.entries(flags)) { if (key.startsWith("_")) continue; @@ -87,13 +86,20 @@ function extractParams(flags: GlobalFlags): Record { return params; } +/** 遥测依赖:authMethod 由调用方(telemetryStage)从解析结果算好后传值——遥测不该拿到凭证能力。 */ +export interface TrackingDeps { + identity: Identity; + settings: Settings; + authMethod?: "api-key" | "access-token"; +} + export async function trackCommandExecution( - config: Config, + deps: TrackingDeps, commandPath: string[], - flags: GlobalFlags, + flags: Record, fn: () => Promise, ): Promise { - if (!config.telemetry) { + if (!deps.settings.telemetry) { await fn(); return; } @@ -119,19 +125,13 @@ export async function trackCommandExecution( } finally { const durationMs = Math.round(performance.now() - start); - let authMethod: string | undefined; - if (config.apiKey) authMethod = "api-key"; - else if (config.apiKeyEnv) authMethod = "api-key"; - else if (config.fileApiKey) authMethod = "api-key"; - else if (config.fileAccessToken) authMethod = "access-token"; - const event = createTrackingEvent({ command: commandPath.join(" "), durationMs, success, error: success ? undefined : { message: errorMessage, httpStatus, requestId }, - cliVersion: config.clientVersion ?? "unknown", - authMethod, + cliVersion: deps.identity.version, + authMethod: deps.authMethod, params: extractParams(flags), }); diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 066b50f..0bf5963 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -1,4 +1,6 @@ -import type { Config } from "../config/schema.ts"; +import type { Identity, Settings } from "../config/schema.ts"; +import type { ConfigStore } from "../config/store.ts"; +import type { AuthStore } from "../auth/store.ts"; import type { Client } from "../client/client.ts"; // ── Flag definitions ───────────────────────────────────────────────────────── @@ -99,25 +101,32 @@ export const GLOBAL_FLAGS = { } satisfies FlagsDef; export type GlobalFlags = ParsedFlags; -/** A command's full flags: global + its own flags, inferred in one pass. */ -export type Flags = ParsedFlags; /** - * What a command's `run` receives: use `client` for all network calls (its - * credential is already injected per the command's `auth`), `config` for - * settings, and `flags` for parsed arguments. Never handle tokens or baseUrl. + * What a command's `run` receives: `client` for all network calls (its + * credential is already injected per the command's `auth`), `settings` for the + * resolved configuration surface, `identity` for product identity, and `flags` + * for parsed arguments. Never handle tokens or baseUrl. */ export interface CommandContext { + /** 静态产品身份(binName/version/npmPackage/clientName)。 */ + identity: Identity; + /** flag/env/file 解析后的有效配置面。 */ + settings: Settings; + /** 只含本命令声明的 flag;全局 flag 经 settings 读。 */ + flags: ParsedFlags; /** Network surface; the credential for the command's `auth` is pre-injected. */ client: Client; - config: Config; - flags: Flags; + /** 惰性访问器,lint 限定 commands/config/** 使用。 */ + configStore(): ConfigStore; + /** 惰性访问器,lint 限定 commands/auth/** 使用。 */ + authStore(): AuthStore; } // ── Command ────────────────────────────────────────────────────────────────── /** * A command. Generic over its flags `F` so `run`/`validate` receive precisely - * typed flags (`Flags` = global + own flags). Stored heterogeneously as + * typed flags (`ParsedFlags` = 命令自有 flag). Stored heterogeneously as * {@link AnyCommand}; the precise typing lives at the `defineCommand` call site. */ export interface Command { @@ -135,7 +144,7 @@ export interface Command { * → UsageError; undefined to pass. Single-flag `required` is enforced by the * parser — use this for rules spanning flags or depending on a flag's *value*. */ - validate?: (flags: Flags) => string | undefined; + validate?: (flags: ParsedFlags) => string | undefined; run: (ctx: CommandContext) => Promise; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 41e9f66..4e9e431 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -4,7 +4,6 @@ export type { FlagDef, FlagsDef, ParsedFlags, - Flags, GlobalFlags, } from "./command.ts"; export { defineCommand, GLOBAL_FLAGS } from "./command.ts"; diff --git a/packages/core/src/utils/output-dir.ts b/packages/core/src/utils/output-dir.ts index a00217f..5e9a0f6 100644 --- a/packages/core/src/utils/output-dir.ts +++ b/packages/core/src/utils/output-dir.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; -import type { Config } from "../config/schema.ts"; +import type { Settings } from "../config/schema.ts"; const DEFAULT_OUTPUT_DIR = () => join(homedir(), "bailian-output"); @@ -17,10 +17,10 @@ const DEFAULT_OUTPUT_DIR = () => join(homedir(), "bailian-output"); * Creates the directory if it doesn't exist. */ export function resolveOutputDir( - config: Config, + settings: Settings, options?: { flagDir?: string; subDir?: string }, ): string { - const base = options?.flagDir || config.outputDir || DEFAULT_OUTPUT_DIR(); + const base = options?.flagDir || settings.outputDir || DEFAULT_OUTPUT_DIR(); const dir = options?.subDir ? join(base, options.subDir) : base; if (!existsSync(dir)) { diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts new file mode 100644 index 0000000..fa697ea --- /dev/null +++ b/packages/core/tests/config-priority.test.ts @@ -0,0 +1,165 @@ +import { expect, test } from "vite-plus/test"; +import type { ConfigFile, Settings } from "../src/config/schema.ts"; +import { buildSettings, type ResolutionSources } from "../src/config/loader.ts"; +import { resolveApiKey, resolveConsole, resolveModelBaseUrl } from "../src/auth/resolver.ts"; + +// 行为锁定:锁住各字段的 flag/env/file 优先级链,统一为 flag>env>file>默认 +// (baseUrl 原为 flag>file>env,2026-07 前置 commit 翻转)。buildSettings 与 +// resolver 都是纯函数,sources 直接构造,无需环境隔离。 + +function src(s: { + flags?: ResolutionSources["flags"]; + env?: Record; + file?: ConfigFile; +}): ResolutionSources { + return { flags: s.flags ?? {}, file: s.file ?? {}, env: s.env ?? {} }; +} + +const resolve = (s: Parameters[0]): Settings => buildSettings(src(s)); + +test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => { + const flags = { baseUrl: "https://flag.example.com" }; + const env = { DASHSCOPE_BASE_URL: "https://env.example.com" }; + const file: ConfigFile = { base_url: "https://file.example.com" }; + expect(resolveModelBaseUrl(src({ flags, env, file }))).toBe("https://flag.example.com"); + expect(resolveModelBaseUrl(src({ env, file }))).toBe("https://env.example.com"); + expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com"); + expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com"); +}); + +test("output:flag > env > file > text", () => { + const env = { DASHSCOPE_OUTPUT: "json" }; + const file: ConfigFile = { output: "json" }; + expect(resolve({ flags: { output: "text" }, env, file }).output).toBe("text"); + expect(resolve({ env, file: { output: "text" } }).output).toBe("json"); + expect(resolve({ file }).output).toBe("json"); + expect(resolve({}).output).toBe("text"); +}); + +test("timeout:flag > 合法 env > file > 300;非法 env 被跳过;非法 flag 抛错", () => { + const file: ConfigFile = { timeout: 30 }; + expect(resolve({ flags: { timeout: 10 }, env: { DASHSCOPE_TIMEOUT: "20" }, file }).timeout).toBe( + 10, + ); + expect(resolve({ env: { DASHSCOPE_TIMEOUT: "20" }, file }).timeout).toBe(20); + expect(resolve({ env: { DASHSCOPE_TIMEOUT: "abc" }, file }).timeout).toBe(30); + expect(resolve({ env: { DASHSCOPE_TIMEOUT: "-5" }, file }).timeout).toBe(30); + expect(resolve({}).timeout).toBe(300); + expect(() => resolve({ flags: { timeout: -1 } })).toThrow(/Timeout/); +}); + +test("workspaceId:env > file(没有全局 flag 这一源)", () => { + const file: ConfigFile = { workspace_id: "ws-file" }; + expect(resolve({ env: { BAILIAN_WORKSPACE_ID: "ws-env" }, file }).workspaceId).toBe("ws-env"); + expect(resolve({ file }).workspaceId).toBe("ws-file"); + expect(resolve({}).workspaceId).toBeUndefined(); +}); + +test("console 三元组:flag > file,无兜底(默认值由 gateway 层兜)", () => { + const file: ConfigFile = { + console_region: "cn-shanghai", + console_site: "international", + console_switch_agent: 111, + }; + const fromFlags = resolve({ + flags: { consoleRegion: "ap-southeast-1", consoleSite: "domestic", consoleSwitchAgent: 222 }, + file, + }); + expect(fromFlags.consoleRegion).toBe("ap-southeast-1"); + expect(fromFlags.consoleSite).toBe("domestic"); + expect(fromFlags.consoleSwitchAgent).toBe(222); + const fromFile = resolve({ file }); + expect(fromFile.consoleRegion).toBe("cn-shanghai"); + expect(fromFile.consoleSite).toBe("international"); + expect(fromFile.consoleSwitchAgent).toBe(111); + expect(resolve({}).consoleRegion).toBeUndefined(); + expect(resolve({}).consoleSite).toBeUndefined(); +}); + +test("verbose:flag 或 DASHSCOPE_VERBOSE=1(无 file 源;env 非 1 不生效)", () => { + expect(resolve({ flags: { verbose: true } }).verbose).toBe(true); + expect(resolve({ env: { DASHSCOPE_VERBOSE: "1" } }).verbose).toBe(true); + expect(resolve({ env: { DASHSCOPE_VERBOSE: "0" } }).verbose).toBe(false); + expect(resolve({}).verbose).toBe(false); +}); + +test("telemetry:DO_NOT_TRACK=1 一票否决 > file > 默认 true", () => { + expect(resolve({ env: { DO_NOT_TRACK: "1" }, file: { telemetry: true } }).telemetry).toBe(false); + expect(resolve({ file: { telemetry: false } }).telemetry).toBe(false); + expect(resolve({}).telemetry).toBe(true); +}); + +test("noColor:NO_COLOR 只看存在性(空串也算);非 TTY 下恒为 true", () => { + expect(resolve({ env: { NO_COLOR: "" } }).noColor).toBe(true); + if (!process.stdout.isTTY) expect(resolve({}).noColor).toBe(true); +}); + +test("apiKey 凭证:flag > env > file,source 字段随之;无 key 抛 AUTH", () => { + const all = src({ + flags: { apiKey: "sk-flag" }, + env: { DASHSCOPE_API_KEY: "sk-env" }, + file: { api_key: "sk-file" }, + }); + expect(resolveApiKey(all)).toMatchObject({ token: "sk-flag", source: "flag" }); + const envFile = src({ env: { DASHSCOPE_API_KEY: "sk-env" }, file: { api_key: "sk-file" } }); + expect(resolveApiKey(envFile)).toMatchObject({ token: "sk-env", source: "env" }); + const fileOnly = src({ file: { api_key: "sk-file" } }); + expect(resolveApiKey(fileOnly)).toMatchObject({ token: "sk-file", source: "config" }); + expect(() => resolveApiKey(src({}))).toThrow(/No API key/); +}); + +test("console 凭证:token 仅 file 源;目标 flag > file > 默认;无 token 抛 AUTH", () => { + const cred = resolveConsole( + src({ + flags: { consoleRegion: "ap-southeast-1" }, + file: { access_token: "tok", console_site: "international", console_switch_agent: 7 }, + }), + ); + expect(cred).toMatchObject({ + token: "tok", + region: "ap-southeast-1", + site: "international", + switchAgent: 7, + }); + expect(resolveConsole(src({ file: { access_token: "tok" } }))).toMatchObject({ + region: "cn-beijing", + site: "domestic", + }); + expect(() => resolveConsole(src({}))).toThrow(/console access token/); +}); + +test("default*Model / outputDir:仅 file 源", () => { + const c = resolve({ + file: { default_text_model: "qwen-max", default_video_model: "wan-x", output_dir: "/tmp/out" }, + }); + expect(c.defaultTextModel).toBe("qwen-max"); + expect(c.defaultVideoModel).toBe("wan-x"); + expect(c.outputDir).toBe("/tmp/out"); + expect(resolve({}).defaultTextModel).toBeUndefined(); +}); + +test("buildSettings:concurrent 仅 flag 源", () => { + expect(resolve({ flags: { concurrent: 4 } }).concurrent).toBe(4); + expect(resolve({}).concurrent).toBeUndefined(); +}); + +test("quiet/yes/dryRun/async/nonInteractive:仅 flag 源,直通", () => { + const on = resolve({ + flags: { quiet: true, yes: true, dryRun: true, async: true, nonInteractive: true }, + }); + expect(on).toMatchObject({ + quiet: true, + yes: true, + dryRun: true, + async: true, + nonInteractive: true, + }); + const off = resolve({}); + expect(off).toMatchObject({ + quiet: false, + yes: false, + dryRun: false, + async: false, + nonInteractive: false, + }); +}); diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts new file mode 100644 index 0000000..e94f1bd --- /dev/null +++ b/packages/core/tests/config-store.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { expect, test } from "vite-plus/test"; +import { makeConfigStore } from "../src/config/store.ts"; +import { makeAuthStore } from "../src/auth/store.ts"; + +/** 在隔离的临时配置目录里执行,结束后恢复环境。 */ +async function inTempConfigDir(fn: () => Promise): Promise { + const saved = process.env.BAILIAN_CONFIG_DIR; + const dir = mkdtempSync(join(tmpdir(), "bl-store-")); + process.env.BAILIAN_CONFIG_DIR = dir; + try { + await fn(); + } finally { + if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = saved; + rmSync(dir, { recursive: true, force: true }); + } +} + +test("ConfigStore:write 合并写入,undefined 键删除,unset 删键", async () => { + await inTempConfigDir(async () => { + const store = makeConfigStore(); + await store.write({ output: "json", timeout: 60, workspace_id: "ws-1" }); + expect(store.read()).toMatchObject({ output: "json", timeout: 60, workspace_id: "ws-1" }); + + await store.write({ output: "text", timeout: undefined }); + const after = store.read(); + expect(after.output).toBe("text"); + expect(after.timeout).toBeUndefined(); + + await store.unset(["workspace_id"]); + expect(store.read().workspace_id).toBeUndefined(); + expect(store.path.endsWith("config.json")).toBe(true); + }); +}); + +test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () => { + await inTempConfigDir(async () => { + const store = makeAuthStore({ flags: {}, file: {}, env: {} }); + await store.login({ + api_key: "sk-1", + access_token: "tok-1", + workspace_id: "ws-1", + console_site: "international", + }); + expect(makeConfigStore().read()).toMatchObject({ + api_key: "sk-1", + access_token: "tok-1", + workspace_id: "ws-1", + console_site: "international", + }); + + expect(await store.logout("console")).toBe(true); + expect(makeConfigStore().read().access_token).toBeUndefined(); + expect(makeConfigStore().read().api_key).toBe("sk-1"); + + expect(await store.logout("all")).toBe(true); + expect(makeConfigStore().read().api_key).toBeUndefined(); + expect(await store.logout("all")).toBe(false); + + // 非凭证键不受 logout 影响 + expect(makeConfigStore().read().workspace_id).toBe("ws-1"); + }); +}); diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 6bb1695..8cda549 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vite-plus/test"; -import type { Config } from "../src/index.ts"; +import type { Identity, Settings } from "../src/index.ts"; import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts"; import { parseConfigFile } from "../src/config/schema.ts"; import { @@ -8,20 +8,27 @@ import { resolveWatermark, } from "../src/utils/boolean-flag.ts"; -function testConfig(overrides: Partial = {}): Config { +function testDeps(identity: Partial = {}): { identity: Identity; settings: Settings } { return { - baseUrl: "https://dashscope.aliyuncs.com", - output: "json", - timeout: 30, - verbose: false, - quiet: true, - noColor: true, - yes: true, - dryRun: false, - nonInteractive: true, - async: false, - telemetry: true, - ...overrides, + identity: { + binName: "bl", + version: "0.0.0-test", + npmPackage: "bailian-cli", + clientName: "bailian-cli", + ...identity, + }, + settings: { + output: "json", + timeout: 30, + verbose: false, + quiet: true, + noColor: true, + yes: true, + dryRun: false, + nonInteractive: true, + async: false, + telemetry: true, + }, }; } @@ -104,9 +111,8 @@ test("request uses injected client identity for User-Agent", async () => { }; try { - await request(testConfig({ clientName: "test-client", clientVersion: "9.8.7" }), { + await request(testDeps({ clientName: "test-client", version: "9.8.7" }), { url: "https://example.test", - noAuth: true, }); } finally { globalThis.fetch = originalFetch; @@ -130,9 +136,8 @@ test("request propagates caller AbortSignal to fetch", async () => { }; }); - const requestPromise = request(testConfig(), { + const requestPromise = request(testDeps(), { url: "https://example.test", - noAuth: true, signal: controller.signal, }); try { @@ -163,8 +168,9 @@ test("McpClient uses injected client identity for initialize and User-Agent", as try { const client = new McpClient( - testConfig({ apiKey: "sk-test", clientName: "test-client", clientVersion: "9.8.7" }), + testDeps({ clientName: "test-client", version: "9.8.7" }), "https://mcp.example.test", + "sk-test", ); await client.initialize(); } finally { diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 9b0275a..417cf7f 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -9,8 +9,19 @@ import { runCommandStage, type RunContext, } from "./middleware.ts"; -import type { AnyCommand, Config, GlobalFlags } from "bailian-cli-core"; -import { GLOBAL_FLAGS, UsageError, loadConfig, flushTelemetry, Client } from "bailian-cli-core"; +import type { AnyCommand, FlagsDef, GlobalFlags, Identity, ParsedFlags } from "bailian-cli-core"; +import { + GLOBAL_FLAGS, + UsageError, + buildSources, + buildSettings, + describeAuthState, + resolveModelBaseUrl, + makeConfigStore, + makeAuthStore, + flushTelemetry, + Client, +} from "bailian-cli-core"; import { setupProxyFromEnv } from "./proxy.ts"; import { handleError } from "./error-handler.ts"; import { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; @@ -21,8 +32,8 @@ export interface CliOptions { binName: string; /** Product version for `--version` output, telemetry and update checks. */ version: string; - /** Telemetry client name (e.g. "bailian-cli", "rag-cli"). Defaults to `binName`. */ - clientName?: string; + /** User-Agent / telemetry client name (e.g. "bailian-cli", "rag-cli")。必填,无默认。 */ + clientName: string; /** npm package name for self-update (e.g. "bailian-cli", "bailian-cli-rag"). */ npmPackage: string; } @@ -31,6 +42,13 @@ export interface Cli { run(argv?: string[]): Promise; } +/** 从解析结果里挑出给定 key 的子集(全局/命令 flag 分流用)。 */ +function pick(obj: Record, keys: string[]): Record { + const out: Record = {}; + for (const key of keys) if (key in obj) out[key] = obj[key]; + return out; +} + /** * 进程级一次性设置:代理初始化、Ctrl+C、stdout EPIPE。 * 属进程生命周期行为,装一次即可,不进 per-command 中间件。 @@ -62,22 +80,13 @@ function installProcessHandlers(binName: string): void { */ export function createCli(commands: Record, opts: CliOptions): Cli { const registry = new CommandRegistry(commands, opts.binName); - const clientName = opts.clientName ?? opts.binName; - const { binName, version, npmPackage } = opts; + const { binName, version, npmPackage, clientName } = opts; + const identity: Identity = { binName, version, npmPackage, clientName }; installProcessHandlers(binName); const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]); - function buildConfig(flags: GlobalFlags): Config { - const config = loadConfig(flags); - config.clientName = clientName; - config.clientVersion = version; - config.binName = binName; - config.npmPackage = npmPackage; - return config; - } - /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ function renderHelp(path: string[], argv: string[]): void { registry.printHelp(path, process.stderr); @@ -85,8 +94,8 @@ export function createCli(commands: Record, opts: CliOptions let hasKey = false; try { - const config = buildConfig(parseFlags(argv, GLOBAL_FLAGS)); - hasKey = !!(config.apiKey || config.apiKeyEnv || config.fileApiKey || config.fileAccessToken); + const auth = describeAuthState(buildSources(parseFlags(argv, GLOBAL_FLAGS) as GlobalFlags)); + hasKey = !!(auth.apiKey || auth.console); } catch { /* unparseable global flags on the bare invocation — fall through to welcome */ } @@ -112,25 +121,32 @@ export function createCli(commands: Record, opts: CliOptions case "run": { try { - // 解析 flag + 跨 flag 校验:任何用法问题都抛 UsageError。 - const flags = parseFlags(res.rest, { + // 解析后分流:全局 flag 进 sources,命令声明的进 ctx.flags;同名的两边都进。 + const parsed = parseFlags(res.rest, { ...GLOBAL_FLAGS, ...res.command.flags, - }) as GlobalFlags; - const invalid = res.command.validate?.(flags); + }) as Record; + const globals = pick(parsed, Object.keys(GLOBAL_FLAGS)) as GlobalFlags; + const ownFlags = pick( + parsed, + Object.keys(res.command.flags ?? {}), + ) as ParsedFlags; + const invalid = res.command.validate?.(ownFlags); if (invalid) throw new UsageError(invalid); - // 校验通过 → 准备配置、进中间件执行命令 - const config = buildConfig(flags); + // 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。 + const sources = buildSources(globals); + const settings = buildSettings(sources); const ctx: RunContext = { - binName, - version, - npmPackage, + identity, path: res.path, command: res.command, - config, - flags, - client: new Client(config), + flags: ownFlags, + settings, + sources, + configStore: () => makeConfigStore(), + authStore: () => makeAuthStore(sources), + client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }), }; await runMiddleware(ctx); await flushTelemetry(1000); diff --git a/packages/runtime/src/error-handler.ts b/packages/runtime/src/error-handler.ts index 0cf55a2..a03f97e 100644 --- a/packages/runtime/src/error-handler.ts +++ b/packages/runtime/src/error-handler.ts @@ -6,7 +6,7 @@ const LABEL_WIDTH = 13; /** Binary name used in error hints; set by handleError() so its helpers can read it. */ let binName: string; -/** Short reminder; full resolution order matches `loadConfig` in bailian-cli-core. */ +/** Short reminder; full resolution order matches `resolveApiKey` in bailian-cli-core. */ function baseUrlHint(): string { return `If the DashScope host is wrong, check baseUrl (--base-url, ${binName} config show, or DASHSCOPE_BASE_URL).`; } diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 4460472..20a10fa 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -1,8 +1,21 @@ -import type { AnyCommand, Config, GlobalFlags, ApiKeyCredential } from "bailian-cli-core"; +import type { + AnyCommand, + ApiKeyCredential, + AuthStore, + ConfigStore, + ConsoleCredential, + FlagsDef, + Identity, + ParsedFlags, + ResolutionSources, + Settings, +} from "bailian-cli-core"; import { Client, - resolveApiKeyCredential, - resolveConsoleCredential, + describeAuthState, + resolveApiKey, + resolveConsole, + resolveModelBaseUrl, trackCommandExecution, } from "bailian-cli-core"; import { maybeShowStatusBar } from "./output/status-bar.ts"; @@ -10,18 +23,25 @@ import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-che /** * What each middleware stage gets for the invocation in flight: the matched - * `command` with its `path`/`config`/`flags`, and the `client` (populated by + * `command` with its `path`/`settings`/`flags`, and the `client` (populated by * {@link authStage}). A stage reads these and may augment them before `next()`. */ export interface RunContext { - readonly binName: string; - readonly version: string; - readonly npmPackage: string; + /** 静态产品身份(binName/version/npmPackage/clientName)。 */ + readonly identity: Identity; /** The matched command path, e.g. ["speech","recognize"]. */ readonly path: string[]; readonly command: AnyCommand; - config: Config; - flags: GlobalFlags; + /** 只含本命令声明的 flag(分流后);全局 flag 在 sources/settings。 */ + flags: ParsedFlags; + /** 解析后的有效配置面(命令的新读取面;双轨迁移期与 config 并存)。 */ + settings: Settings; + /** 解析源:provider/访问器用;业务命令不可见(窄视图类型不含此字段)。 */ + sources: ResolutionSources; + /** 惰性访问器,lint 限定 commands/config/** 使用。 */ + configStore(): ConfigStore; + /** 惰性访问器,lint 限定 commands/auth/** 使用。 */ + authStore(): AuthStore; /** Network surface with the credential baked in — set by {@link authStage}. */ client: Client; } @@ -43,30 +63,45 @@ export function compose(stack: Middleware[]): (ctx: RunContext) => Promise /** * Bake the credential for the command's declared `auth` into `ctx.client`, and - * gate: no credential → throw before the command runs (skipped under --dry-run, - * which needs none). `auth: "none"` commands keep a credential-less client. + * gate: no credential → throw before the command runs. dry-run 例外:两域解析失败 + * 都不抛(dry-run 只打印请求,无需凭证;console 的 dry-run 展示读 settings.console*)。 + * `auth: "none"` commands keep a credential-less client. */ export const authStage: Middleware = async (ctx, next) => { - const { command, config } = ctx; + const { command, settings, sources } = ctx; + const base = { identity: ctx.identity, settings, baseUrl: resolveModelBaseUrl(sources) }; if (command.auth === "apiKey") { let cred: ApiKeyCredential | undefined; try { - cred = await resolveApiKeyCredential(config); + cred = resolveApiKey(sources); } catch (err) { - if (!config.dryRun) throw err; // dry-run only prints the request — no key needed + if (!settings.dryRun) throw err; } - ctx.client = new Client(config, cred); - if (cred) maybeShowStatusBar(config, cred.token, cred); - } else if (command.auth === "console" && !config.dryRun) { - const cred = await resolveConsoleCredential(config); - ctx.client = new Client(config, undefined, cred); + ctx.client = new Client({ ...base, apiCred: cred }); + if (cred) maybeShowStatusBar(settings, cred.token, cred); + } else if (command.auth === "console") { + let cred: ConsoleCredential | undefined; + try { + cred = resolveConsole(sources); + } catch (err) { + if (!settings.dryRun) throw err; + } + if (cred) ctx.client = new Client({ ...base, consoleCred: cred }); } await next(); }; /** Record command execution (start / success / failure) around the command. */ -export const telemetryStage: Middleware = (ctx, next) => - trackCommandExecution(ctx.config, ctx.path, ctx.flags, next); +export const telemetryStage: Middleware = (ctx, next) => { + const auth = describeAuthState(ctx.sources); + const authMethod = auth.apiKey ? "api-key" : auth.console ? "access-token" : undefined; + return trackCommandExecution( + { identity: ctx.identity, settings: ctx.settings, authMethod }, + ctx.path, + ctx.flags, + next, + ); +}; /** * Kick off a debounced update check before the command, then — only on success @@ -74,19 +109,21 @@ export const telemetryStage: Middleware = (ctx, next) => * if `next()` throws, the notice is skipped (no update nag on failure). */ export const versionCheckStage: Middleware = async (ctx, next) => { - const pending = checkForUpdate(ctx.version, ctx.npmPackage).catch(() => {}); + const pending = checkForUpdate(ctx.identity.version, ctx.identity.npmPackage).catch(() => {}); await next(); await pending; const isUpdateCommand = ctx.path.length === 1 && ctx.path[0] === "update"; const newVersion = getPendingUpdateNotification(); - if (newVersion && !ctx.config.quiet && !isUpdateCommand) { + if (newVersion && !ctx.settings.quiet && !isUpdateCommand) { const isTTY = process.stderr.isTTY; const yellow = isTTY ? "\x1b[33m" : ""; const cyan = isTTY ? "\x1b[36m" : ""; const reset = isTTY ? "\x1b[0m" : ""; - process.stderr.write(`\n ${yellow}Update available: ${ctx.version} → ${newVersion}${reset}\n`); - process.stderr.write(` Run ${cyan}${ctx.binName} update${reset} to upgrade\n\n`); + process.stderr.write( + `\n ${yellow}Update available: ${ctx.identity.version} → ${newVersion}${reset}\n`, + ); + process.stderr.write(` Run ${cyan}${ctx.identity.binName} update${reset} to upgrade\n\n`); } }; diff --git a/packages/runtime/src/output/status-bar.ts b/packages/runtime/src/output/status-bar.ts index be69215..607ef21 100644 --- a/packages/runtime/src/output/status-bar.ts +++ b/packages/runtime/src/output/status-bar.ts @@ -1,5 +1,5 @@ import { homedir } from "os"; -import { maskToken, type Config, type ApiKeyCredential } from "bailian-cli-core"; +import { maskToken, type Settings, type ApiKeyCredential } from "bailian-cli-core"; const reset = "\x1b[0m"; const dim = "\x1b[2m"; @@ -12,18 +12,14 @@ function tildePath(p: string): string { } export function maybeShowStatusBar( - config: Config, + settings: Settings, token: string, - resolved?: ApiKeyCredential, + resolved: ApiKeyCredential, ): void { - if (config.quiet || !process.stderr.isTTY) return; + if (settings.quiet || !process.stderr.isTTY) return; - const filePath = config.configPath ? tildePath(config.configPath) : "~/.bailian/config.json"; - const authTag = resolved - ? `${resolved.source} · api-key` - : config.apiKey - ? "flag · api-key" - : "config"; + const filePath = settings.configPath ? tildePath(settings.configPath) : "~/.bailian/config.json"; + const authTag = `${resolved.source} · api-key`; const maskedKey = maskToken(token); process.stderr.write( diff --git a/packages/runtime/src/pipeline/bl-config.ts b/packages/runtime/src/pipeline/bl-config.ts index ccb3088..19789c6 100644 --- a/packages/runtime/src/pipeline/bl-config.ts +++ b/packages/runtime/src/pipeline/bl-config.ts @@ -1,25 +1,49 @@ -import { loadConfig, type Config, type GlobalFlags } from "bailian-cli-core"; +import { + Client, + buildSettings, + readConfigFile, + resolveApiKey, + resolveModelBaseUrl, + type ApiKeyCredential, + type Identity, + type ResolutionSources, + type Settings, +} from "bailian-cli-core"; -const PIPELINE_FLAGS: GlobalFlags = { - output: "json", - nonInteractive: true, - noColor: true, - quiet: true, - verbose: false, - yes: false, - dryRun: false, - help: false, - version: false, - async: false, -}; +/** Pipeline step 的迷你边界:client(带 model 域凭证,若有)+ 有效 settings。 */ +export interface PipelineEnv { + client: Client; + settings: Settings; +} /** - * Build a Config suitable for in-process API calls from within pipeline steps. - * Uses the same config resolution (env vars, config file) as the CLI itself, - * but forces JSON output + non-interactive + quiet mode. + * Build the in-process env for pipeline steps. Uses the same source resolution + * as the CLI itself (env vars, config file; no CLI flags), but forces JSON + * output + non-interactive + quiet mode. */ -export function buildPipelineConfig(): Config { - const config = loadConfig(PIPELINE_FLAGS); - config.clientName = "bailian-cli"; - return config; +export function buildPipelineEnv(): PipelineEnv { + const sources: ResolutionSources = { flags: {}, file: readConfigFile(), env: process.env }; + const settings: Settings = { + ...buildSettings(sources), + output: "json", + nonInteractive: true, + noColor: true, + quiet: true, + }; + const identity: Identity = { + binName: "bl", + version: "0.0.0-dev", + npmPackage: "bailian-cli", + clientName: "bailian-cli", + }; + let apiCred: ApiKeyCredential | undefined; + try { + apiCred = resolveApiKey(sources); + } catch { + /* 无 key:步骤真正发请求时由 Client 报错 */ + } + return { + client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources), apiCred }), + settings, + }; } diff --git a/packages/runtime/src/pipeline/executor.ts b/packages/runtime/src/pipeline/executor.ts index 2ce59e0..aac5a3f 100644 --- a/packages/runtime/src/pipeline/executor.ts +++ b/packages/runtime/src/pipeline/executor.ts @@ -1,5 +1,5 @@ import { PipelineError, toPipelineError } from "./errors.ts"; -import { buildPipelineConfig } from "./bl-config.ts"; +import { buildPipelineEnv } from "./bl-config.ts"; import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts"; import { evaluateCondition, @@ -134,7 +134,7 @@ async function executePipelineInternal( } } - const blConfig = buildPipelineConfig(); + const blEnv = buildPipelineEnv(); const plan = buildExecutionPlan(pipeline); const concurrency = normalizeConcurrency(options.concurrency); const reports: PipelineStepReport[] = []; @@ -281,7 +281,7 @@ async function executePipelineInternal( artifacts, emit, options, - blConfig, + blEnv, stepDispatcher, ); inFlight.set(planStep.step.id, executing); @@ -355,7 +355,7 @@ async function executePlanStep( artifacts: StepArtifact[], emit: (event: PipelineLifecycleEvent) => Promise, options: ExecutePipelineOptions, - blConfig: unknown, + blEnv: unknown, stepDispatcher: StepDispatcher, ): Promise { const maxAttempts = Math.max(1, Math.floor(planStep.step.retry?.maxAttempts ?? 1)); @@ -417,7 +417,7 @@ async function executePlanStep( options, stepEvent(planStep), emit, - blConfig, + blEnv, stepDispatcher, ); outputs.set(planStep.step.id, output); @@ -520,7 +520,7 @@ async function executeWithTimeout( options: ExecutePipelineOptions, planStepEvent: PipelineEventStep, emit: (event: PipelineLifecycleEvent) => Promise, - blConfig: unknown, + blEnv: unknown, stepDispatcher: StepDispatcher, ): Promise { const timeoutSeconds = parseTimeoutSeconds(step.timeout) ?? options.timeoutSeconds; @@ -546,7 +546,7 @@ async function executeWithTimeout( timeoutSeconds, blRequestTimeoutSeconds: options.blRequestTimeoutSeconds, emitEvent, - blConfig, + blEnv, }; if (!timeoutSeconds) return await stepDispatcher.executeStep(step.type, input, ctx); diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index 8a16f20..41a1dee 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -3,7 +3,6 @@ * Bypasses the CLI command handler layer and calls requestJson/request directly. */ import { - requestJson, chatPath, imagePath, imageSyncPath, @@ -11,12 +10,9 @@ import { taskPath, speechSynthesizePath, speechRecognizePath, - resolveFileUrl, - resolveApiKeyCredential, stripUndefined, resolveBooleanFlag, resolveWatermark, - type Config, type ChatRequest, type ChatResponse, type DashScopeImageRequest, @@ -33,6 +29,7 @@ import { import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { PipelineError } from "../errors.ts"; +import type { PipelineEnv } from "../bl-config.ts"; import type { StepContext } from "../types.ts"; import { resolveImageSize } from "../../utils/image-size.ts"; import { downloadFile } from "../../utils/download.ts"; @@ -51,7 +48,7 @@ export interface TextChatInput { } export async function textChat( - config: Config, + env: PipelineEnv, input: TextChatInput, ctx: StepContext, ): Promise { @@ -81,9 +78,9 @@ export async function textChat( } } - const url = config.baseUrl + chatPath(); - const response = await requestJson(config, { - url, + const url = chatPath(); + const response = await env.client.requestJson({ + path: url, method: "POST", body, timeout: ctx.blRequestTimeoutSeconds, @@ -102,7 +99,7 @@ export interface VisionDescribeInput { } export async function visionDescribe( - config: Config, + env: PipelineEnv, input: VisionDescribeInput, ctx: StepContext, ): Promise { @@ -118,8 +115,7 @@ export async function visionDescribe( if (input.video) { let videoUrl = input.video; if (isLocalFile(videoUrl)) { - const credential = await resolveApiKeyCredential(config); - videoUrl = await resolveFileUrl(videoUrl, credential.token, model, { signal: ctx.signal }); + videoUrl = await env.client.uploadFile(videoUrl, model, { signal: ctx.signal }); } contentArray.push({ type: "video_url", video_url: { url: videoUrl } }); } @@ -128,8 +124,7 @@ export async function visionDescribe( for (const img of images) { let imageUrl = img; if (isLocalFile(img)) { - const credential = await resolveApiKeyCredential(config); - imageUrl = await resolveFileUrl(img, credential.token, model, { signal: ctx.signal }); + imageUrl = await env.client.uploadFile(img, model, { signal: ctx.signal }); } contentArray.push({ type: "image_url", image_url: { url: imageUrl } }); } @@ -141,9 +136,9 @@ export async function visionDescribe( messages: [{ role: "user", content: contentArray }], }; - const url = config.baseUrl + chatPath(); - return await requestJson(config, { - url, + const url = chatPath(); + return await env.client.requestJson({ + path: url, method: "POST", body, signal: ctx.signal, @@ -172,7 +167,7 @@ export interface ImageGenerateInput { } export async function imageGenerate( - config: Config, + env: PipelineEnv, input: ImageGenerateInput, ctx: StepContext, ): Promise { @@ -208,9 +203,9 @@ export async function imageGenerate( }; if (useSync) { - const url = config.baseUrl + imageSyncPath(); - const response = await requestJson(config, { - url, + const url = imageSyncPath(); + const response = await env.client.requestJson({ + path: url, method: "POST", body, signal: ctx.signal, @@ -223,16 +218,16 @@ export async function imageGenerate( return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; } else { // Async mode: submit then poll - const url = config.baseUrl + imagePath(); - const asyncResp = await requestJson(config, { - url, + const url = imagePath(); + const asyncResp = await env.client.requestJson({ + path: url, method: "POST", body, async: true, signal: ctx.signal, }); const taskId = asyncResp.output.task_id; - const result = await pollTask(config, taskId, ctx); + const result = await pollTask(env, taskId, ctx); const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); if (saved) result.saved = saved; @@ -257,7 +252,7 @@ export interface ImageEditInput { } export async function imageEdit( - config: Config, + env: PipelineEnv, input: ImageEditInput, ctx: StepContext, ): Promise { @@ -282,8 +277,7 @@ export async function imageEdit( for (const img of images) { let imageUrl = img; if (isLocalFile(img)) { - const credential = await resolveApiKeyCredential(config); - imageUrl = await resolveFileUrl(img, credential.token, model, { signal: ctx.signal }); + imageUrl = await env.client.uploadFile(img, model, { signal: ctx.signal }); } content.push({ image: imageUrl }); } @@ -305,9 +299,9 @@ export async function imageEdit( }; if (useSync) { - const url = config.baseUrl + imageSyncPath(); - const response = await requestJson(config, { - url, + const url = imageSyncPath(); + const response = await env.client.requestJson({ + path: url, method: "POST", body, signal: ctx.signal, @@ -319,16 +313,16 @@ export async function imageEdit( const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; } else { - const url = config.baseUrl + imagePath(); - const asyncResp = await requestJson(config, { - url, + const url = imagePath(); + const asyncResp = await env.client.requestJson({ + path: url, method: "POST", body, async: true, signal: ctx.signal, }); const taskId = asyncResp.output.task_id; - const result = await pollTask(config, taskId, ctx); + const result = await pollTask(env, taskId, ctx); const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); if (saved) result.saved = saved; @@ -381,7 +375,7 @@ export interface VideoGenerateInput { } export async function videoGenerate( - config: Config, + env: PipelineEnv, input: VideoGenerateInput, ctx: StepContext, ): Promise { @@ -396,8 +390,7 @@ export async function videoGenerate( let resolvedImageUrl: string | undefined; if (input.image) { if (isLocalFile(input.image)) { - const credential = await resolveApiKeyCredential(config); - resolvedImageUrl = await resolveFileUrl(input.image, credential.token, model, { + resolvedImageUrl = await env.client.uploadFile(input.image, model, { signal: ctx.signal, }); } else { @@ -425,9 +418,9 @@ export async function videoGenerate( }; stripUndefined(body.parameters as Record); - const url = config.baseUrl + videoGeneratePath(); - const asyncResp = await requestJson(config, { - url, + const url = videoGeneratePath(); + const asyncResp = await env.client.requestJson({ + path: url, method: "POST", body, async: true, @@ -438,7 +431,7 @@ export async function videoGenerate( const pollIntervalMs = (input["poll-interval"] ?? 10) * 1000; const timeoutMs = (ctx.timeoutSeconds ?? 900) * 1000; - return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx); + return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx); } // --- speech/synthesize --- @@ -461,7 +454,7 @@ export interface SpeechSynthesizeInput { } export async function speechSynthesize( - config: Config, + env: PipelineEnv, input: SpeechSynthesizeInput, ctx: StepContext, ): Promise { @@ -496,9 +489,9 @@ export async function speechSynthesize( }; stripUndefined(body.input as Record); - const url = config.baseUrl + speechSynthesizePath(); - const response = await requestJson(config, { - url, + const url = speechSynthesizePath(); + const response = await env.client.requestJson({ + path: url, method: "POST", body, signal: ctx.signal, @@ -527,7 +520,7 @@ export interface SpeechRecognizeInput { } export async function speechRecognize( - config: Config, + env: PipelineEnv, input: SpeechRecognizeInput, ctx: StepContext, ): Promise { @@ -542,9 +535,8 @@ export async function speechRecognize( const fileUrls: string[] = []; for (const u of rawUrls) { if (isLocalFile(u)) { - const credential = await resolveApiKeyCredential(config); fileUrls.push( - await resolveFileUrl(u, credential.token, input.model || "fun-asr", { + await env.client.uploadFile(u, input.model || "fun-asr", { signal: ctx.signal, }), ); @@ -567,9 +559,9 @@ export async function speechRecognize( }; stripUndefined(body.parameters as Record); - const url = config.baseUrl + speechRecognizePath(); - const asyncResp = await requestJson(config, { - url, + const url = speechRecognizePath(); + const asyncResp = await env.client.requestJson({ + path: url, method: "POST", body, async: true, @@ -580,7 +572,7 @@ export async function speechRecognize( const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000; const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000; - return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx); + return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx); } // --- Shared: task polling --- @@ -615,17 +607,17 @@ function flattenTaskResponse(resp: DashScopeTaskResponse): Record> { const pollIntervalMs = 3000; - const timeoutMs = config.timeout * 1000; - return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx); + const timeoutMs = env.settings.timeout * 1000; + return await pollTaskWithOptions(env, taskId, pollIntervalMs, timeoutMs, ctx); } async function pollTaskWithOptions( - config: Config, + env: PipelineEnv, taskId: string, pollIntervalMs: number, timeoutMs: number, @@ -644,9 +636,9 @@ async function pollTaskWithOptions( await delay(pollIntervalMs, ctx?.signal); attempt++; - const url = config.baseUrl + taskPath(taskId); - const result = await requestJson(config, { - url, + const url = taskPath(taskId); + const result = await env.client.requestJson({ + path: url, method: "GET", signal: ctx?.signal, }); diff --git a/packages/runtime/src/pipeline/steps/bl-steps.ts b/packages/runtime/src/pipeline/steps/bl-steps.ts index 4720a77..41f325a 100644 --- a/packages/runtime/src/pipeline/steps/bl-steps.ts +++ b/packages/runtime/src/pipeline/steps/bl-steps.ts @@ -1,5 +1,5 @@ import { registerStep } from "../dispatcher.ts"; -import { buildPipelineConfig } from "../bl-config.ts"; +import { buildPipelineEnv, type PipelineEnv } from "../bl-config.ts"; import { isRecord } from "../utils.ts"; import { textChat, @@ -10,26 +10,25 @@ import { speechSynthesize, speechRecognize, } from "./bl-api.ts"; -import type { Config } from "bailian-cli-core"; import type { StepDispatcher } from "../dispatcher.ts"; import type { StepArtifact, StepContext, StepOutputSchema, StepResult } from "../types.ts"; // --- Direct API call dispatch --- type DirectApiHandler = ( - config: Config, + env: PipelineEnv, input: Record, ctx: StepContext, ) => Promise; const DIRECT_API_HANDLERS: Record = { - "text/chat": (config, input, ctx) => textChat(config, input, ctx), - "vision/describe": (config, input, ctx) => visionDescribe(config, input, ctx), - "image/generate": (config, input, ctx) => imageGenerate(config, input, ctx), - "image/edit": (config, input, ctx) => imageEdit(config, input, ctx), - "video/generate": (config, input, ctx) => videoGenerate(config, input, ctx), - "speech/synthesize": (config, input, ctx) => speechSynthesize(config, input, ctx), - "speech/recognize": (config, input, ctx) => speechRecognize(config, input, ctx), + "text/chat": (env, input, ctx) => textChat(env, input, ctx), + "vision/describe": (env, input, ctx) => visionDescribe(env, input, ctx), + "image/generate": (env, input, ctx) => imageGenerate(env, input, ctx), + "image/edit": (env, input, ctx) => imageEdit(env, input, ctx), + "video/generate": (env, input, ctx) => videoGenerate(env, input, ctx), + "speech/synthesize": (env, input, ctx) => speechSynthesize(env, input, ctx), + "speech/recognize": (env, input, ctx) => speechRecognize(env, input, ctx), }; // Build result with artifact extraction from the raw API data @@ -168,8 +167,8 @@ async function executeDirectBlStep( throw new Error(`No direct API handler registered for step: ${id}`); } - const config = (ctx.blConfig as Config | undefined) ?? buildPipelineConfig(); - const data = await handler(config, input, ctx); + const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv(); + const data = await handler(env, input, ctx); const builder = RESULT_BUILDERS[id]; if (builder) { return builder(data); diff --git a/packages/runtime/src/pipeline/types.ts b/packages/runtime/src/pipeline/types.ts index f271d81..6bc43f6 100644 --- a/packages/runtime/src/pipeline/types.ts +++ b/packages/runtime/src/pipeline/types.ts @@ -337,5 +337,5 @@ export interface StepContext { timeoutSeconds?: number; blRequestTimeoutSeconds?: number; emitEvent?: (event: Record) => void | Promise; - blConfig?: unknown; + blEnv?: unknown; } diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 6304159..fc921b7 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -44,6 +44,15 @@ export class CommandRegistry { } private register(path: string, command: AnyCommand): void { + // 同名守卫:dispatch 的分流规则依赖"命令对全局 flag 的遮蔽都是同型重声明"。 + for (const [key, def] of Object.entries(command.flags ?? {})) { + const global = (GLOBAL_FLAGS as Record)[key]; + if (global && global.type !== (def as { type: string }).type) { + throw new Error( + `Command "${path}" redeclares global flag "${key}" with type "${(def as { type: string }).type}" (global is "${global.type}").`, + ); + } + } const parts = path.split(" "); let node = this.root; for (const part of parts) { diff --git a/packages/runtime/src/utils/concurrent.ts b/packages/runtime/src/utils/concurrent.ts index 79a0dd2..3f0598e 100644 --- a/packages/runtime/src/utils/concurrent.ts +++ b/packages/runtime/src/utils/concurrent.ts @@ -8,12 +8,11 @@ * const results = await runConcurrent(3, config, () => callApi()); */ -import type { Config, GlobalFlags } from "bailian-cli-core"; +import type { Settings } from "bailian-cli-core"; -/** Resolve concurrency from flags (defaults to 1). */ -export function getConcurrency(flags: GlobalFlags): number { - const n = flags.concurrent as number | undefined; - return Math.max(1, n ?? 1); +/** Resolve concurrency from settings(`--concurrent`,defaults to 1). */ +export function getConcurrency(settings: Settings): number { + return Math.max(1, settings.concurrent ?? 1); } /** @@ -21,14 +20,14 @@ export function getConcurrency(flags: GlobalFlags): number { * Returns all resolved results in order. * If any single task fails, the error propagates (Promise.all semantics). * - * @param n Number of concurrent executions - * @param config CLI config (for logging) - * @param task Async factory to execute - * @param label Optional label for status output (e.g. "requests", "tasks") + * @param n Number of concurrent executions + * @param settings Resolved settings (for logging) + * @param task Async factory to execute + * @param label Optional label for status output (e.g. "requests", "tasks") */ export async function runConcurrent( n: number, - config: Config, + settings: Settings, task: (index: number) => Promise, label = "requests", ): Promise { @@ -37,7 +36,7 @@ export async function runConcurrent( return [result]; } - if (!config.quiet) { + if (!settings.quiet) { process.stderr.write(`[Concurrent: ${n} ${label}]\n`); } diff --git a/packages/runtime/src/utils/polling.ts b/packages/runtime/src/utils/polling.ts index 1387e65..6a164e3 100644 --- a/packages/runtime/src/utils/polling.ts +++ b/packages/runtime/src/utils/polling.ts @@ -1,7 +1,8 @@ -import { BailianError, ExitCode, requestJson, type Config } from "bailian-cli-core"; +import { BailianError, ExitCode, type Client, type Settings } from "bailian-cli-core"; import { createSpinner } from "../output/progress.ts"; export interface PollOptions { + /** Absolute task URL (Client passes absolute URLs through as-is). */ url: string; intervalSec: number; timeoutSec: number; @@ -11,17 +12,17 @@ export interface PollOptions { getErrorMessage?: (data: unknown) => string | undefined; } -export async function poll(config: Config, opts: PollOptions): Promise { +export async function poll(client: Client, settings: Settings, opts: PollOptions): Promise { const deadline = Date.now() + opts.timeoutSec * 1000; const spinner = createSpinner("Polling..."); - if (!config.quiet) spinner.start(); + if (!settings.quiet) spinner.start(); try { while (Date.now() < deadline) { - const data = await requestJson(config, { url: opts.url }); + const data = await client.requestJson({ path: opts.url }); - if (opts.getStatus && !config.quiet) { + if (opts.getStatus && !settings.quiet) { spinner.update(`Status: ${opts.getStatus(data)}`); } @@ -32,7 +33,7 @@ export async function poll(config: Config, opts: PollOptions): Promise { if (opts.isFailed(data)) { spinner.stop("Failed."); - if (config.verbose) { + if (settings.verbose) { process.stderr.write(`[verbose] Task response: ${JSON.stringify(data, null, 2)}\n`); } const errMsg = opts.getErrorMessage?.(data);