mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" : "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ export type {
|
||||
FlagDef,
|
||||
FlagsDef,
|
||||
ParsedFlags,
|
||||
Flags,
|
||||
GlobalFlags,
|
||||
} from "./command.ts";
|
||||
export { defineCommand, GLOBAL_FLAGS } from "./command.ts";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type { Config } from "../config/schema.ts";
|
||||
import type { Settings } from "../config/schema.ts";
|
||||
|
||||
const DEFAULT_OUTPUT_DIR = () => join(homedir(), "bailian-output");
|
||||
|
||||
@@ -17,10 +17,10 @@ const DEFAULT_OUTPUT_DIR = () => join(homedir(), "bailian-output");
|
||||
* Creates the directory if it doesn't exist.
|
||||
*/
|
||||
export function resolveOutputDir(
|
||||
config: Config,
|
||||
settings: Settings,
|
||||
options?: { flagDir?: string; subDir?: string },
|
||||
): string {
|
||||
const base = options?.flagDir || config.outputDir || DEFAULT_OUTPUT_DIR();
|
||||
const base = options?.flagDir || settings.outputDir || DEFAULT_OUTPUT_DIR();
|
||||
const dir = options?.subDir ? join(base, options.subDir) : base;
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).`;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -337,5 +337,5 @@ export interface StepContext {
|
||||
timeoutSeconds?: number;
|
||||
blRequestTimeoutSeconds?: number;
|
||||
emitEvent?: (event: Record<string, unknown>) => void | Promise<void>;
|
||||
blConfig?: unknown;
|
||||
blEnv?: unknown;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user