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
This commit is contained in:
若麒
2026-07-06 15:59:33 +08:00
parent ae88f7a4ad
commit d31b7f83ca
85 changed files with 1614 additions and 838 deletions
+371
View File
@@ -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<ConfigFile>): Promise<void>; // writeConfigFile 本身 async
unset(keys: (keyof ConfigFile)[]): Promise<void>; // 删 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<void>;
logout(): void;
}
```
命令上下文 `packages/core/src/types/command.ts`(单一类型 + 惰性访问器):
```ts
export interface CommandContext<F extends FlagsDef = FlagsDef> {
identity: Identity;
settings: Settings;
flags: ParsedFlags<F>; // 只含本命令声明的 flag(不含全局)
client: Client;
configStore(): ConfigStore; // 惰性;lint 限定只在 commands/config/** 使用
authStore(): AuthStore; // 惰性;lint 限定只在 commands/auth/** 使用
}
export interface Command<F extends FlagsDef = FlagsDef> {
description: string;
auth: AuthRequirement; // 凭证要求,不变
flags?: F;
usageArgs?;
exampleArgs?;
notes?;
validate?: (flags: ParsedFlags<F>) => string | undefined; // 收窄:只看命令自己的 flag
run: (ctx: CommandContext<F>) => Promise<void>;
}
export const defineCommand = <F extends FlagsDef>(spec: Command<F>) => spec;
```
运行时组合根(runtime 内部,中间件用;命令拿到的是窄视图 `CommandContext`):
```ts
export interface RunContext<F extends FlagsDef = FlagsDef> extends CommandContext<F> {
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<T>(opts): Promise<T> // 用 apiCred.token + apiCred.baseUrl;UA 用 identity
request(opts): Promise<Response>
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<unknown>
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<GlobalFlags>;
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,依赖注入留给使用者。
@@ -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));
},
});
+7 -7
View File
@@ -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);
+3 -3
View File
@@ -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;
}
@@ -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<void> {
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<unknown>(testConfig, requestOpts);
await requestJson<unknown>(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<string, unknown>;
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<void> {
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<string, unknown>;
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;
+10 -11
View File
@@ -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<string, unknown>;
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();
}
},
+11 -27
View File
@@ -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<boolean> {
const file = readConfigFile() as Record<string, unknown>;
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");
@@ -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: "<uid>", 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 <key> or DASHSCOPE_API_KEY`,
`Console gateway: ${config.binName} auth login --console`,
`API key (model): ${identity.binName} auth login --api-key <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)`);
}
},
});
+8 -12
View File
@@ -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<string, unknown>;
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<ConfigFile>);
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);
}
},
+9 -14
View File
@@ -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<string, unknown> = {
...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);
@@ -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,
);
@@ -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(
+10 -10
View File
@@ -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<string, unknown>);
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<DashScopeImageSyncResponse>({
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);
@@ -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<typeof GENERATE_FLAGS>;
type GenerateFlags = ParsedFlags<typeof GENERATE_FLAGS>;
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<void> {
const results = await runConcurrent(concurrent, config, () =>
const results = await runConcurrent(concurrent, settings, () =>
client.requestJson<DashScopeImageSyncResponse>({ 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<void> {
const responses = await runConcurrent(
concurrent,
config,
settings,
() =>
client.requestJson<DashScopeAsyncResponse>({
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<DashScopeTaskResponse>(config, {
return poll<DashScopeTaskResponse>(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<void> {
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<string, unknown> = {
@@ -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);
+3 -3
View File
@@ -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,
+5 -5
View File
@@ -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);
}
+3 -3
View File
@@ -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;
}
+6 -6
View File
@@ -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<typeof ADD_FLAGS>;
type AddFlags = ParsedFlags<typeof ADD_FLAGS>;
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 {
@@ -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);
@@ -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 {
@@ -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);
@@ -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)"}`);
@@ -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: "<id>", description: "Memory library ID" },
} satisfies FlagsDef;
type SearchFlags = Flags<typeof SEARCH_FLAGS>;
type SearchFlags = ParsedFlags<typeof SEARCH_FLAGS>;
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 {
@@ -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);
+7 -7
View File
@@ -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`);
}
}
@@ -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<typeof RUN_FLAGS>;
type RunFlags = ParsedFlags<typeof RUN_FLAGS>;
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);
@@ -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",
@@ -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);
},
});
@@ -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);
},
});
+4 -4
View File
@@ -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<string, unknown> = {
pageNo: 1,
pageSize: 50,
@@ -224,6 +224,6 @@ export default defineCommand({
return;
}
printTable(models, config.noColor);
printTable(models, settings.noColor);
},
});
@@ -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;
+7 -7
View File
@@ -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<string, unknown> = { 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") {
@@ -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<typeof RECOGNIZE_FLAGS>;
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
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<string, unknown>);
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<DashScopeASRTaskResult>(config, {
const result = await poll<DashScopeASRTaskResult>(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`);
}
}
@@ -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<typeof SYNTHESIZE_FLAGS>;
type SynthesizeFlags = ParsedFlags<typeof SYNTHESIZE_FLAGS>;
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<string, unknown>);
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<void> {
const concurrent = getConcurrency(flags);
const concurrent = getConcurrency(settings);
const results = await runConcurrent(concurrent, config, () =>
const results = await runConcurrent(concurrent, settings, () =>
client.requestJson<DashScopeTTSResponse>({
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`);
}
}
+9 -9
View File
@@ -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<typeof CHAT_FLAGS>;
type ChatFlags = ParsedFlags<typeof CHAT_FLAGS>;
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);
+4 -4
View File
@@ -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" : "";
+4 -4
View File
@@ -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<string, string>();
@@ -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);
},
});
@@ -132,10 +132,10 @@ export default defineCommand({
validate: (f) =>
!f.model && !f.all ? "Provide --model <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,
+13 -12
View File
@@ -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 <id>\`.`,
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <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<string, unknown> = {
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);
}
},
});
@@ -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;
}
+11 -11
View File
@@ -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<DashScopeTaskResponse>(config, {
const result = await poll<DashScopeTaskResponse>(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);
},
@@ -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<DashScopeAsyncResponse>({
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<DashScopeTaskResponse>(config, {
return poll<DashScopeTaskResponse>(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 });
}),
);
+11 -11
View File
@@ -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<DashScopeTaskResponse>(config, {
const result = await poll<DashScopeTaskResponse>(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);
},
@@ -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;
}
@@ -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,
@@ -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);
},
});
@@ -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);
}
});
+3 -3
View File
@@ -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<ModelProfile[]> {
const sources: ModelSource[] = [
new CatalogSource({ onPrepareStart: options?.onPrepareStart }),
new ApiSource(config),
new ApiSource(settings),
];
for (const source of sources) {
+11 -12
View File
@@ -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<number[]> {
const url = `${config.baseUrl}/compatible-mode/v1/embeddings`;
export async function embedQuery(client: Client, text: string): Promise<number[]> {
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<number[]
encoding_format: "float",
};
const response = await requestJson<{
const response = await client.requestJson<{
data: { index: number; embedding: number[] }[];
}>(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<number[][]> {
const url = `${config.baseUrl}/compatible-mode/v1/embeddings`;
async function embedBatch(client: Client, texts: string[]): Promise<number[][]> {
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<number[][]>
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<string, string>):
}
export async function buildAndCacheEmbeddings(
config: Config,
client: Client,
models: ModelProfile[],
): Promise<ModelEmbedding[]> {
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);
}
+5 -6
View File
@@ -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<IntentProfile> {
const url = config.baseUrl + chatPath();
export async function analyzeIntent(client: Client, input: string): Promise<IntentProfile> {
const url = chatPath();
const body = {
model: INTENT_MODEL,
@@ -21,8 +20,8 @@ export async function analyzeIntent(config: Config, input: string): Promise<Inte
};
try {
const response = await requestJson<ChatResponse>(config, {
url,
const response = await client.requestJson<ChatResponse>({
path: url,
method: "POST",
body,
timeout: 5000,
+4 -4
View File
@@ -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 ?? [];
+7 -8
View File
@@ -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<ChatResponse>(config, {
url,
const response = await client.requestJson<ChatResponse>({
path: url,
method: "POST",
body,
});
+9 -4
View File
@@ -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<string, unknown>): 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<ModelProfile[]> {
// Public model catalog — no console token (advisor runs unauthenticated).
const eff = effectiveConsoleGatewayConfig(this.settings);
const call = (api: string, data: Record<string, unknown>) =>
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];
-48
View File
@@ -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<string, unknown>;
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<void> {
await ensureConfigDir();
const path = getConfigPath();
let existing: Record<string, unknown> = {};
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<void> {
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 */
}
}
+7 -2
View File
@@ -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";
+36 -28
View File
@@ -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<ApiKeyCredential> {
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<ApiKeyCre
);
}
/** Console-domain credential — an access token from `bl auth login --console`. */
export async function resolveConsoleCredential(config: Config): Promise<ConsoleCredential> {
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<AuthState> {
/** 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 */
}
+64
View File
@@ -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<void>;
/** 清凭证:console 只删 access_token;all 删 api_key + access_token。返回是否有变更。 */
logout(scope: "console" | "all"): Promise<boolean>;
}
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<string, unknown>;
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<string, unknown>;
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;
},
};
}
+31 -19
View File
@@ -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<RequestOpts, "url" | "noAuth"> {
path: string;
}
@@ -20,22 +30,22 @@ export interface ClientRequestOpts extends Omit<RequestOpts, "url" | "noAuth"> {
* 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<Response> {
return request(this.config, this.toOpts(opts));
return request(this.http, this.toOpts(opts));
}
requestJson<T>(opts: ClientRequestOpts): Promise<T> {
return requestJson<T>(this.config, this.toOpts(opts));
return requestJson<T>(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<T>(api: string, data: Record<string, unknown>): Promise<T> {
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<T>;
// region / site / switchAgent 已解析在 consoleCred 里,gateway 不再回读 config。
return callConsoleGateway(this.deps.consoleCred, this.deps.settings.timeout, {
api,
data,
}) as Promise<T>;
}
}
+14 -24
View File
@@ -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<string, string>;
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<Response> {
export async function request(deps: HttpDeps, opts: RequestOpts): Promise<Response> {
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<string, string> = {
"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<Respon
headers["X-DashScope-OssResourceResolve"] = "enable";
}
if (!opts.noAuth) {
const credential = await resolveApiKeyCredential(config);
headers["Authorization"] = `Bearer ${credential.token}`;
if (config.verbose) {
console.error(`> ${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<Respon
signal: requestSignal.signal,
}).finally(requestSignal.cleanup);
if (config.verbose) {
if (deps.settings.verbose) {
console.error(`< ${res.status} ${res.statusText}`);
const reqId = res.headers.get("x-request-id");
if (reqId) {
@@ -118,8 +108,8 @@ function createRequestSignal(
return { signal: controller.signal, cleanup };
}
export async function requestJson<T>(config: Config, opts: RequestOpts): Promise<T> {
const res = await request(config, opts);
export async function requestJson<T>(deps: HttpDeps, opts: RequestOpts): Promise<T> {
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 };
+14 -15
View File
@@ -6,15 +6,14 @@
*
* Protocol flow: initialize → tools/list → tools/call
*
* Auth: always sends `Authorization: Bearer <DashScope sk-key>` resolved via
* `resolveApiKeyCredential`. Bailian MCPs all accept this; non-Bailian endpoints
* Auth: always sends `Authorization: Bearer <DashScope sk-key>` 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<void> {
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<string, string> = {
"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}`);
}
+4 -2
View File
@@ -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";
+30 -30
View File
@@ -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<string, unknown>): Promise<vo
renameSync(tmp, path);
}
export function loadConfig(flags: GlobalFlags): Config {
const file = readConfigFile();
/**
* 解析的三个来源,dispatch 边界一次构建。flags 收 Partial:ParsedFlags 里 switch 是
* 必填 boolean,收 Partial 让 pipeline 等无 flag 场景传 {} 即可。
*/
export interface ResolutionSources {
flags: Partial<GlobalFlags>;
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<GlobalFlags>): 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),
};
}
+20 -16
View File
@@ -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;
+38
View File
@@ -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<ConfigFile>): Promise<void>;
/** 删除指定键。 */
unset(keys: (keyof ConfigFile)[]): Promise<void>;
path: string;
}
export function makeConfigStore(): ConfigStore {
return {
read: () => readConfigFile(),
async write(patch) {
const existing = readConfigFile() as Record<string, unknown>;
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<string, unknown>;
for (const key of keys) delete existing[key];
await writeConfigFile(existing);
},
get path() {
return getConfigPath();
},
};
}
+24 -28
View File
@@ -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<Settings, "consoleRegion" | "consoleSite" | "consoleSwitchAgent">,
): {
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<string, unknown>;
/** 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<unknown> {
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<string, string> = {
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)}`,
+14 -14
View File
@@ -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<string, unknown> {
function extractParams(flags: Record<string, unknown>): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(flags)) {
if (key.startsWith("_")) continue;
@@ -87,13 +86,20 @@ function extractParams(flags: GlobalFlags): Record<string, unknown> {
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<string, unknown>,
fn: () => Promise<void>,
): Promise<void> {
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),
});
+19 -10
View File
@@ -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<typeof GLOBAL_FLAGS>;
/** A command's full flags: global + its own flags, inferred in one pass. */
export type Flags<F extends FlagsDef> = ParsedFlags<typeof GLOBAL_FLAGS & F>;
/**
* 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<F extends FlagsDef = FlagsDef> {
/** 静态产品身份(binName/version/npmPackage/clientName)。 */
identity: Identity;
/** flag/env/file 解析后的有效配置面。 */
settings: Settings;
/** 只含本命令声明的 flag;全局 flag 经 settings 读。 */
flags: ParsedFlags<F>;
/** Network surface; the credential for the command's `auth` is pre-injected. */
client: Client;
config: Config;
flags: Flags<F>;
/** 惰性访问器,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<F>` = global + own flags). Stored heterogeneously as
* typed flags (`ParsedFlags<F>` = 命令自有 flag). Stored heterogeneously as
* {@link AnyCommand}; the precise typing lives at the `defineCommand` call site.
*/
export interface Command<F extends FlagsDef = FlagsDef> {
@@ -135,7 +144,7 @@ export interface Command<F extends FlagsDef = FlagsDef> {
* → 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<F>) => string | undefined;
validate?: (flags: ParsedFlags<F>) => string | undefined;
run: (ctx: CommandContext<F>) => Promise<void>;
}
-1
View File
@@ -4,7 +4,6 @@ export type {
FlagDef,
FlagsDef,
ParsedFlags,
Flags,
GlobalFlags,
} from "./command.ts";
export { defineCommand, GLOBAL_FLAGS } from "./command.ts";
+3 -3
View File
@@ -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)) {
+165
View File
@@ -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<string, string>;
file?: ConfigFile;
}): ResolutionSources {
return { flags: s.flags ?? {}, file: s.file ?? {}, env: s.env ?? {} };
}
const resolve = (s: Parameters<typeof src>[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,
});
});
+66
View File
@@ -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<void>): Promise<void> {
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");
});
});
+25 -19
View File
@@ -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> = {}): Config {
function testDeps(identity: Partial<Identity> = {}): { 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 {
+45 -29
View File
@@ -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<void>;
}
/** 从解析结果里挑出给定 key 的子集(全局/命令 flag 分流用)。 */
function pick(obj: Record<string, unknown>, keys: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, AnyCommand>, 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<string, AnyCommand>, 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<string, AnyCommand>, 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<string, unknown>;
const globals = pick(parsed, Object.keys(GLOBAL_FLAGS)) as GlobalFlags;
const ownFlags = pick(
parsed,
Object.keys(res.command.flags ?? {}),
) as ParsedFlags<FlagsDef>;
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);
+1 -1
View File
@@ -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).`;
}
+62 -25
View File
@@ -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<FlagsDef>;
/** 解析后的有效配置面(命令的新读取面;双轨迁移期与 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<void>
/**
* 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`);
}
};
+6 -10
View File
@@ -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(
+44 -20
View File
@@ -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,
};
}
+7 -7
View File
@@ -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<void>,
options: ExecutePipelineOptions,
blConfig: unknown,
blEnv: unknown,
stepDispatcher: StepDispatcher,
): Promise<PipelineStepReport> {
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<void>,
blConfig: unknown,
blEnv: unknown,
stepDispatcher: StepDispatcher,
): Promise<StepResult> {
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);
+51 -59
View File
@@ -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<ChatResponse> {
@@ -81,9 +78,9 @@ export async function textChat(
}
}
const url = config.baseUrl + chatPath();
const response = await requestJson<ChatResponse>(config, {
url,
const url = chatPath();
const response = await env.client.requestJson<ChatResponse>({
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<ChatResponse> {
@@ -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<ChatResponse>(config, {
url,
const url = chatPath();
return await env.client.requestJson<ChatResponse>({
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<unknown> {
@@ -208,9 +203,9 @@ export async function imageGenerate(
};
if (useSync) {
const url = config.baseUrl + imageSyncPath();
const response = await requestJson<DashScopeImageSyncResponse>(config, {
url,
const url = imageSyncPath();
const response = await env.client.requestJson<DashScopeImageSyncResponse>({
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<DashScopeAsyncResponse>(config, {
url,
const url = imagePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
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<unknown> {
@@ -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<DashScopeImageSyncResponse>(config, {
url,
const url = imageSyncPath();
const response = await env.client.requestJson<DashScopeImageSyncResponse>({
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<DashScopeAsyncResponse>(config, {
url,
const url = imagePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
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<unknown> {
@@ -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<string, unknown>);
const url = config.baseUrl + videoGeneratePath();
const asyncResp = await requestJson<DashScopeAsyncResponse>(config, {
url,
const url = videoGeneratePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
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<unknown> {
@@ -496,9 +489,9 @@ export async function speechSynthesize(
};
stripUndefined(body.input as Record<string, unknown>);
const url = config.baseUrl + speechSynthesizePath();
const response = await requestJson<DashScopeTTSResponse>(config, {
url,
const url = speechSynthesizePath();
const response = await env.client.requestJson<DashScopeTTSResponse>({
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<unknown> {
@@ -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<string, unknown>);
const url = config.baseUrl + speechRecognizePath();
const asyncResp = await requestJson<DashScopeAsyncResponse>(config, {
url,
const url = speechRecognizePath();
const asyncResp = await env.client.requestJson<DashScopeAsyncResponse>({
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<string, unknow
}
async function pollTask(
config: Config,
env: PipelineEnv,
taskId: string,
ctx?: StepContext,
): Promise<Record<string, unknown>> {
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<DashScopeTaskResponse>(config, {
url,
const url = taskPath(taskId);
const result = await env.client.requestJson<DashScopeTaskResponse>({
path: url,
method: "GET",
signal: ctx?.signal,
});
+11 -12
View File
@@ -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<string, unknown>,
ctx: StepContext,
) => Promise<unknown>;
const DIRECT_API_HANDLERS: Record<string, DirectApiHandler> = {
"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);
+1 -1
View File
@@ -337,5 +337,5 @@ export interface StepContext {
timeoutSeconds?: number;
blRequestTimeoutSeconds?: number;
emitEvent?: (event: Record<string, unknown>) => void | Promise<void>;
blConfig?: unknown;
blEnv?: unknown;
}
+9
View File
@@ -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<string, { type: string }>)[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) {
+10 -11
View File
@@ -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<T>(
n: number,
config: Config,
settings: Settings,
task: (index: number) => Promise<T>,
label = "requests",
): Promise<T[]> {
@@ -37,7 +36,7 @@ export async function runConcurrent<T>(
return [result];
}
if (!config.quiet) {
if (!settings.quiet) {
process.stderr.write(`[Concurrent: ${n} ${label}]\n`);
}
+7 -6
View File
@@ -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<T>(config: Config, opts: PollOptions): Promise<T> {
export async function poll<T>(client: Client, settings: Settings, opts: PollOptions): Promise<T> {
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<T>(config, { url: opts.url });
const data = await client.requestJson<T>({ 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<T>(config: Config, opts: PollOptions): Promise<T> {
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);