Initial commit

This commit is contained in:
若麒
2026-05-28 18:37:07 +08:00
commit 1533e2013e
220 changed files with 26860 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+46
View File
@@ -0,0 +1,46 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies & build output
node_modules
dist
dist-ssr
tools/generated
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Crawled document metadata (generated)
*.meta.json
# AI agent / IDE config (machine-local — keep out of the public repo)
.claude/worktrees/
.claude/settings.json
.claude/settings.local.json
.cursor/
.qwen/
.playwright-mcp/
.pnpm-store/
# Test & scene outputs
/test/
packages/cli/scene/**/outputs/
# Environment variables (sensitive data)
.env
+1
View File
@@ -0,0 +1 @@
vp staged
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["VoidZero.vite-plus-extension-pack"]
}
+106
View File
@@ -0,0 +1,106 @@
# bailian-cli — AI 维护指南
本文件是 AI agent 维护本仓库时的契约。每次进入项目首先读这里,从下方"业务场景索引"挑一条,跳到对应的详细文档,按它的清单完成改动。
## 项目地图
monorepo 双包结构:
- `packages/cli``bailian-cli` 包,CLI 命令、UI、入口
- `packages/core``bailian-cli-core` 包,鉴权 / HTTP / 类型,纯逻辑层
### `packages/cli` 目录要点
```
packages/cli/
├── src/
│ ├── main.ts # 入口、鉴权分支、调用 registry
│ ├── registry.ts # 命令树解析、动态 help(读 catalog)
│ ├── commands/
│ │ ├── catalog.ts # 命令总表(登记处,构建脚本也读它)
│ │ ├── index.ts # re-export commands
│ │ └── <group>/...ts # 各命令 defineCommand 实现
│ ├── output/ # CLI 输出、prompt、progress
│ └── urls.ts # 控制台/文档 URL(仅 cli)
└── tests/e2e/
```
Skill / 命令手册不再随 npm 包发布,改由独立的 `npx add skills` 机制安装。`tools/generate-reference.ts` 仍然从 `catalog.ts` 生成命令手册到 `tools/generated/reference/`(gitignore,临时),等新机制接入后再迁走。
非代码资产:
- `tools/release.mjs` — 发版自动化
- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册(临时输出到 `tools/generated/reference/`)
- `README.md` / `README_CN.md` — npm 和 GitHub 主页
约定:
- core 是纯库,不依赖 cli(详见下方通用约定)
- 文件路径与命令路径一一对应:`commands/text/chat.ts``bl text chat`
- 单级命令:`commands/<name>.ts`(如 `update.ts`);两级:`commands/<group>/<action>.ts`
- 命令登记在 **`catalog.ts`**;`bl --help``tools/generate-reference.ts` 生成的命令手册同源,见 [command-add-remove.md](docs/agents/command-add-remove.md)
## 业务场景索引
按当前任务从下表挑一条进入对应文档:
| 场景 | 何时进入 | 详见 |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| 发版前自检 | beta / rc / 正式发布到 npm | [docs/agents/release.md](docs/agents/release.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增一份 `docs/agents/<scenario>.md`,把清单沉淀下来。
## 通用约定
下面两条与场景无关,任何改动都适用。每次完成改动后自查。
### 1. cli 和 core 版本号同步
`packages/cli/package.json``packages/core/package.json``version` 字段必须始终相等。一动两动。
### 2. core 是纯库,cli 是 core 的 UI 层
core 不应该知道 cli 的存在。具体表现:
- core 不写 stderr,不调 `process.exit`(用 `console.*``throw`)
- core 抛的 `BailianError`,hint 字符串不出现 `bl xxx` 命令名
- core 不写死域名 / region / 追踪参数(URL 集中在 `packages/cli/src/urls.ts`)
- core 接收 cli 通过 `Config` 注入的 metadata(`clientName` / `clientVersion`)
### 3. 错误处理边界:CLI 不翻译服务端错误
CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的错误**原样透传**。详见 [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md)。
| 错误来源 | 归类 | 处理方式 |
| ---------------------------------------------------- | -------- | ----------------------------------------------------------- |
| 命令解析、缺 flag、参数校验 | **内部** | `BailianError(USAGE)` |
| 文件 I/O(ENOENT/EACCES/...) | **内部** | `BailianError(GENERAL)` + errno-specific hint |
| 本地 credentials 缺失(resolver/ensure-key/AK-SK 等) | **内部** | `BailianError(AUTH)` |
| `fetch` 自身失败(DNS/TCP/TLS/proxy) | **内部** | `BailianError(NETWORK)` + 读 `err.cause.code` 给 errno-hint |
| polling 客户端超时 | **内部** | `BailianError(TIMEOUT)` |
| HTTP 4xx/5xx、HTTP 200 + 业务错码、async task FAILED | **服务** | `BailianError(GENERAL)`,**message 原样透传**,不分类、不替换 |
不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎(详见 `docs/agents/error-hint-change.md` 中的反面 case)。
## 完成改动后的快速验证
```sh
vp check # format + lint + type check
vp test # unit + e2e (e2e 需 API key)
```
## 这份指南本身怎么演化
这套文档不是写完就死,**随真实工作沉淀**。完成每次改动后回看:这次发现的漏点该不该补?是不是一类新场景?
新增场景 / 改主入口 / 跨文档引用规则 → [docs/agents/maintaining-agent-docs.md](docs/agents/maintaining-agent-docs.md)
+53
View File
@@ -0,0 +1,53 @@
# Changelog
All notable changes to `bailian-cli` and `bailian-cli-core` are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The two packages share a single version number — they are always released together.
[中文版](CHANGELOG_CN.md)
## [1.1.0] - 2026-05-28
Initial public release on GitHub. The CLI was previously developed internally; this is the first version published as open source under Apache-2.0.
### Added
Out-of-the-box capabilities your AI agent can compose across complex tasks:
**Model services**
| Capability | Default | Description |
| ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------- |
| Text generation | `qwen3.7-max` | Flagship Max model for the agent era — strong at coding, office work, and long-horizon autonomy |
| Speech synthesis | `cosyvoice-v3-flash` | Multi-voice real-time streaming TTS with enhanced naturalness/emotion; clone from 520s samples |
| Speech recognition | `fun-asr` | 7 Chinese dialects + 20+ Mandarin accents; covers 30 languages |
| Image generation | `qwen-image-2.0` | Fused generation & editing, pro text rendering, photorealism, strong semantic adherence |
| Image editing | `qwen-image-2.0` | Smart editing with multi-image composition |
| Image-to-video | `happyhorse-1.0-i2v` | Faithful text-semantic interpretation, smooth high-quality output |
| Text-to-video | `happyhorse-1.0-t2v` | Vivid motion reproduction with rich detail |
| Reference-to-video | `happyhorse-1.0-r2v` | Up to 9 reference images; stable subject & scene preservation |
| Video editing | `happyhorse-1.0-video-edit` | Natural-language video editing, up to 5 reference images |
| Vision understanding | `qwen-vl` | Long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR |
**Application data**
| Capability | Default | Description |
| -------------- | -------------------------------- | -------------------------------------------------------------------- |
| Knowledge base | Aliyun Model Studio Knowledge | Multimodal RAG CRUD and retrieval; requires AccessKey |
| Memory | Aliyun Model Studio Memory | Cross-session persistence for personalized coherent dialogue |
**Application building**
| Capability | Default | Description |
| -------------- | ---------------- | ------------------------------------------ |
| Workflow calls | Workflow service | Invoke published workflow apps |
| Agent calls | Agent service | Invoke published agent apps |
**Tools**
| Capability | Default | Description |
| ------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------ |
| Web search | `bailian_web_search` | Real-time internet retrieval for accuracy and freshness |
| Temp file upload | Temp upload service | Free temp storage; upload local files for URLs (48-hour validity) |
| Free-quota query | Quota query | Check available free-tier quota by model id |
| API reference | Aliyun Model Studio API reference docs | Auto-integrate Aliyun Model Studio model and app capability APIs during build |
+53
View File
@@ -0,0 +1,53 @@
# 更新日志
`bailian-cli``bailian-cli-core` 的所有重要变更都记录在此。
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。两个包共享一个版本号,总是一起发布。
[English](CHANGELOG.md)
## [1.1.0] - 2026-05-28
GitHub 上的首次公开发布。本项目此前在内部开发,这是首个以 Apache-2.0 协议开源的版本。
### 新增
让您的 AI Agent 开箱就具备以下能力,并可在复杂任务中自动组合调用:
**模型服务**
| 能力 | 默认服务 | 简介 |
| ---------- | ----------------------------- | ----------------------------------------------------------------------------------- |
| 文本生成 | `qwen3.7-max` | 面向智能体时代的旗舰 Max 模型,编程、办公与长周期自主执行能力出色 |
| 语音生成 | `cosyvoice-v3-flash` | 多音色实时流式合成,自然度/情感增强,5-20s 样本即可克隆 |
| 语音识别 | `fun-asr` | 汉语七大方言 + 20+ 口音官话,覆盖 30 种语种 |
| 图像生成 | `qwen-image-2.0` | 图片生成与编辑融合,专业文字渲染、真实质感、强语义遵循 |
| 图像编辑 | `qwen-image-2.0` | 智能编辑,支持多图合成 |
| 图生视频 | `happyhorse-1.0-i2v` | 精准理解文本语义,输出流畅自然的高质量视频 |
| 文生视频 | `happyhorse-1.0-t2v` | 高度还原动态画面,细节丰富 |
| 参考生视频 | `happyhorse-1.0-r2v` | 支持最多 9 张图片参考,稳定主体与场景保持 |
| 视频编辑 | `happyhorse-1.0-video-edit` | 自然语言指令编辑视频,支持最多 5 张图片参考 |
| 视觉理解 | `qwen-vl` | 长视频分析、图表/文档解析、视觉推理、多语言 OCR |
**应用数据**
| 能力 | 默认服务 | 简介 |
| ------ | ---------------- | --------------------------------------------- |
| 知识库 | 阿里云百炼知识库 | 多模态数据知识库增删改查检索,需 AccessKey 认证 |
| 记忆库 | 阿里云百炼记忆库 | 跨会话持久化存储,提供个性化连贯对话体验 |
**应用构建**
| 能力 | 默认服务 | 简介 |
| ---------- | ---------- | -------------------------- |
| 工作流调用 | 工作流服务 | 调用已有的工作流应用服务 |
| 智能体调用 | 智能体服务 | 调用已有的智能体应用服务 |
**工具能力**
| 能力 | 默认服务 | 简介 |
| ------------ | --------------------------------- | ------------------------------------------------------------------------ |
| 联网搜索 | `bailian_web_search` | 实时互联网全栈信息检索,提升回答准确性及时效性 |
| 临时文件上传 | 临时文件上传服务 | 免费临时存储空间,上传本地文件获得 URL(有效期 48 小时) |
| 模型额度查询 | 模型额度查询 | 根据模型 id 查询可以使用的免费额度 |
| 接口文档 | 阿里云百炼模型应用 API 调用参考文档 | 在构建应用的过程中,自动为您的应用集成阿里云百炼模型和应用能力 API |
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+89
View File
@@ -0,0 +1,89 @@
# Contributing to bailian-cli
Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (DashScope). For end-user usage, see [README.md](README.md).
[中文版](CONTRIBUTING_CN.md)
## Prerequisites
- Node.js ≥ 22.12
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
- A DashScope API key for running e2e tests
## Repository layout
```
bailian-cli/
├── packages/
│ ├── cli/ # `bailian-cli` — CLI entry, commands, UI
│ └── core/ # `bailian-cli-core` — auth, HTTP, types
├── docs/agents/ # Scenario-based maintenance guides
├── tools/ # Release & reference generation
├── AGENTS.md # Contract for AI agents
└── README.md
```
## Local setup
```bash
git clone https://github.com/modelstudioai/cli.git bailian-cli
cd bailian-cli
pnpm install
```
### Running the CLI from source
Open two terminals:
```bash
# Terminal 1 — watch-build core
pnpm dev
# Terminal 2 — run any bl command
pnpm bl auth login --api-key sk-xxxxx
pnpm bl text chat --message "hello"
pnpm bl video generate --prompt "a cat walking"
```
## Common scripts
| Command | What it does |
| ---------------- | ------------------------------------------- |
| `pnpm bl <args>` | Run the CLI from source |
| `pnpm dev` | Watch-build `bailian-cli-core` |
| `pnpm check` | Format + lint + type check |
| `pnpm test` | Unit + e2e (e2e needs `DASHSCOPE_API_KEY`) |
| `pnpm ready` | Full pre-PR verification |
## AI Native engineering
This project treats AI agents as first-class maintainers. Three pieces of infrastructure make that work, and they're worth knowing about whether you're a human contributor or an agent:
- **[AGENTS.md](AGENTS.md) + [docs/agents/](docs/agents/)** — A contract for working in this repo. `AGENTS.md` gives the project map and a scenario index; each `docs/agents/<scenario>.md` breaks a recurring task (add a command, change a flag, add a model, change an error hint, …) into a checklist that can be followed end-to-end without prior exploration. New scenarios get sedimented back as they emerge — the docs grow with the project, not from a one-off design phase.
- **E2E test suite** — Cases under [packages/cli/tests/e2e/](packages/cli/tests/e2e/) cover the main flows across text / image / video / speech / understanding / knowledge / memory. They exercise the CLI as a black box against the live DashScope service, so behavior changes (yours or an agent's) are caught the way real users would experience them.
- **Main-flow stress tests** (`pnpm test:stress`) — Concurrent runs against all model capabilities to verify they stay stable under heavy request volume. Catches rate-limit edge cases, race conditions, and silent regressions that single-shot e2e can miss. Run before each release.
**If you are an AI agent working on this repo, read [AGENTS.md](AGENTS.md) first** — it's the entry point that routes you to the right scenario doc.
## Branching & PRs
- Branch from `main`. Name: `feat/<short-name>`, `fix/<short-name>`, `docs/<short-name>`.
- Run `pnpm check` before opening the PR.
- Keep PRs focused — one logical change per PR.
- Commit messages: imperative and scoped. Examples: `feat(image): add --negative-prompt`, `fix(auth): handle expired console token`.
- Do not bump `packages/*/package.json` versions in feature PRs.
## Reporting issues
Bug reports and feature requests both go to https://github.com/modelstudioai/cli/issues. For bugs, please include:
- CLI version (`bl --version`)
- Node version (`node --version`)
- Exact command that failed
- Full output (redact API keys)
## License
By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE).
+89
View File
@@ -0,0 +1,89 @@
# 参与贡献 bailian-cli
`bailian-cli` 是阿里云百炼(DashScope)的官方 CLI。本文是面向**开发者**的指南;终端用户请看 [README_CN.md](README_CN.md)。
[English](CONTRIBUTING.md)
## 环境要求
- Node.js ≥ 22.12
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
- 跑 e2e 需要一个百炼 API Key
## 仓库结构
```
bailian-cli/
├── packages/
│ ├── cli/ # `bailian-cli` —— CLI 入口、命令、UI
│ └── core/ # `bailian-cli-core` —— 鉴权、HTTP、类型
├── docs/agents/ # 场景化维护文档
├── tools/ # 发版与命令手册生成
├── AGENTS.md # AI agent 维护契约
└── README.md
```
## 本地搭建
```bash
git clone https://github.com/modelstudioai/cli.git bailian-cli
cd bailian-cli
pnpm install
```
### 从源码运行 CLI
开两个终端:
```bash
# 终端 1 —— core watch 重建
pnpm dev
# 终端 2 —— 跑任意 bl 命令
pnpm bl auth login --api-key sk-xxxxx
pnpm bl text chat --message "你好"
pnpm bl video generate --prompt "一只走路的猫"
```
## 常用脚本
| 命令 | 作用 |
| ---------------- | ------------------------------------------ |
| `pnpm bl <args>` | 从源码运行 CLI |
| `pnpm dev` | watch-build `bailian-cli-core` |
| `pnpm check` | format + lint + 类型检查 |
| `pnpm test` | 单测 + e2e(e2e 需要 `DASHSCOPE_API_KEY`) |
| `pnpm ready` | 提 PR 前的完整自检 |
## AI Native 工程化
我们把 AI agent 当作一等公民的维护者。下面三套基建让这件事能跑起来,无论你是人类贡献者还是 AI agent,都值得了解一下:
- **[AGENTS.md](AGENTS.md) + [docs/agents/](docs/agents/)** —— 本仓库的维护契约。`AGENTS.md` 给出项目地图和场景索引;每份 `docs/agents/<场景>.md` 把高频任务(加命令、改 flag、加模型、改错误提示……)拆成一份可端到端执行的清单,不需要事先摸索全仓库。新场景随真实工作沉淀回来——这份文档随项目生长,不是一次性的设计产物。
- **E2E 测试体系** —— [packages/cli/tests/e2e/](packages/cli/tests/e2e/) 下的用例覆盖文本 / 图像 / 视频 / 语音 / 理解 / 知识库 / 记忆库的主链路,黑盒方式打真实的 DashScope 服务。任何人或 agent 改了行为,会以真实用户感知到的方式被兜住。
- **主链路压测**(`pnpm test:stress`)—— 对所有模型能力做并发压测,验证它们在大量请求下仍然稳定;同时捕捉单次 e2e 抓不到的限流边界、竞态、静默回归。每次发版前跑。
**如果你是在本仓库上工作的 AI agent,先读 [AGENTS.md](AGENTS.md)** —— 它是会把你引到正确场景文档的入口。
## 分支与 PR
-`main` 拉分支。命名:`feat/<short-name>``fix/<short-name>``docs/<short-name>`
- 提 PR 前先跑 `pnpm check`
- 一个 PR 只解决一类问题。
- 提交信息祈使句、带 scope。例:`feat(image): add --negative-prompt``fix(auth): handle expired console token`
- 功能 PR 不要去动 `packages/*/package.json` 的版本号。
## 反馈问题
Bug 和需求都走 https://github.com/modelstudioai/cli/issues。如果是 bug,请包含:
- CLI 版本(`bl --version`)
- Node 版本(`node --version`)
- 触发问题的完整命令
- 完整输出(请脱敏 API Key)
## License
提交贡献即表示你同意以 [Apache License 2.0](LICENSE) 协议授权。
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Aliyun Model Studio (DashScope) AI Platform
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+145
View File
@@ -0,0 +1,145 @@
<div align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01RSQFUD1jN5IBzHORt_!!6000000004535-2-tps-2440-521.png" alt="Aliyun Model Studio CLI" width="420" />
# >\_ Aliyun Model Studio CLI
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli) · [中文文档](https://unpkg.com/bailian-cli/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
---
_Chat with Qwen, generate images & videos, understand images, call agents,_
_manage memory, search the web — all from your terminal._
_Built for AI Agents. Every command works as a structured tool call._
</div>
## Features
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01Df2LiL1IcCkXJROYz_!!6000000000913-2-tps-759-426.png" alt="bl --help" width="720" />
</p>
## Installation
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
```
> Requires Node.js >= 22.12.
## Quick Start
```bash
# Authenticate
bl auth login --api-key sk-xxxxx
# Chat with Qwen
bl text chat --message "What is DashScope?"
# Multimodal chat (text + image + audio + video)
bl omni --message "Describe this image" --image ./photo.jpg
# Generate an image
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
# Generate a video from local image
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
# Browser login (required for console capability commands)
bl auth login --console
# Browse apps / free-tier quota
bl app list
bl usage free --model qwen3-max
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli)
## Authentication
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key).
```bash
# Option 1: Environment variable
export DASHSCOPE_API_KEY=sk-xxxxx
# Option 2: Login command (persisted to ~/.bailian/config.json)
bl auth login --api-key sk-xxxxx
# Option 3: Per-command flag
bl text chat --api-key sk-xxxxx --message "Hello"
```
### Console Login (OAuth)
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
```
### Alibaba Cloud AK/SK (Knowledge Base only)
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
```bash
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## Configuration
```bash
# View current config
bl config show
# Set defaults
bl config set --key region --value us
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Self-update to latest version
bl update
```
Config file location: `~/.bailian/config.json`
## Links
| Resource | URL |
| :--------------------------- | :-------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
+145
View File
@@ -0,0 +1,145 @@
<div align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01RSQFUD1jN5IBzHORt_!!6000000004535-2-tps-2440-521.png" alt="Aliyun Model Studio CLI" width="420" />
# >\_ Aliyun Model Studio CLI
**阿里云百炼 (DashScope) AI 平台命令行工具**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) · [English](https://unpkg.com/bailian-cli/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
---
_千问对话、图像生成与编辑、视频生成与编辑、图像理解、语音合成与识别、_
_应用调用、记忆管理、知识检索、联网搜索 — 一行命令,触达所有 AI 能力。_
_专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
</div>
## 功能特性
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01Df2LiL1IcCkXJROYz_!!6000000000913-2-tps-759-426.png" alt="bl --help" width="720" />
</p>
## 安装
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
```
> 需要预先安装 Node.js >= 22.12。
## 快速开始
```bash
# 认证
bl auth login --api-key sk-xxxxx
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
# 多模态对话(文本 + 图片 + 音频 + 视频)
bl omni --message "描述这张图片" --image ./photo.jpg
# 生成图片
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
# 图生视频(本地文件自动上传)
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 浏览应用 / 免费额度
bl app list
bl usage free --model qwen3-max
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli)
## 认证方式
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
export DASHSCOPE_API_KEY=sk-xxxxx
# 方式二:登录命令(持久化到 ~/.bailian/config.json
bl auth login --api-key sk-xxxxx
# 方式三:命令行参数
bl text chat --api-key sk-xxxxx --message "你好"
```
### 控制台登录OAuth
控制台能力命令(`app list``usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
```
### 阿里云 AK/SK仅知识库检索
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
```bash
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## 配置
```bash
# 查看当前配置
bl config show
# 设置默认值
bl config set --key region --value us
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# 自更新到最新版本
bl update
```
配置文件位置:`~/.bailian/config.json`
## 相关链接
| 资源 | 地址 |
| :---------------------- | :-------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
+118
View File
@@ -0,0 +1,118 @@
# 鉴权扩展
## 触发条件
- 增加新的鉴权方式(OAuth、SSO、控制台回调登录)
- 增加新的 token 来源(env / config / flag / 文件)
- 调整凭证解析优先级
-`bl auth login` 流程
## 鉴权链路
```
flag 优先 ─→ config 文件 ─→ env var
│ │ │
└──── resolveCredential() (core) ───┐
cli/utils/ensure-key.ts (启动时拦)
命令注入 Authorization 头
```
凭证类型(`AuthMethod`):
- `api-key` — DashScope SK(`sk-...`),走 Bearer 头
- `access-token` — 控制台 OAuth 回调拿到的临时 token,走 Bearer + 不同 endpoint
- `ak/sk` — Alibaba Cloud 标准 AK/SK,走 ROA 签名(只用于知识库)
### 双凭证并存API Key + Console
`~/.bailian/config.json` 可同时保存 `api_key``access_token`。**登录任一种方式不得删除另一种**`bl auth login --api-key` / `--console` 只更新对应字段)。
解析分工:
- `resolveCredential()` — DashScope API 命令(`text chat``file upload`config 里两者都有时 **优先 `api_key`**
- `resolveConsoleGatewayCredential()` — 控制台网关(`app list``usage free``console call`**只用** env/file 的 `access_token`,忽略 `api_key`
必改调用点: 凡 `callConsoleGateway` 必须用 `resolveConsoleGatewayCredential`,不能误用 `resolveCredential`(否则 config 仅有 api_key 时会拿 sk- 打网关)。
`bl auth logout --console` 只清 `access_token`;全量 `bl auth logout` 清两者。
## 必查清单
### A. core 层(类型 + 解析)
- [ ] `packages/core/src/auth/types.ts`:
- 新增 `AuthMethod` 字面量
- 新增 `ResolvedCredential` 字段(如 token 类型 / 过期时间)
- [ ] `packages/core/src/auth/resolver.ts`:
- `resolveCredential()` 增加新分支
- 控制台网关命令用 `resolveConsoleGatewayCredential()`(与 DashScope 解析分离)
- 优先级注释保持清晰(数字标号)
- [ ] `packages/core/src/auth/credentials.ts`:
- 如果新方式需要持久化,加 `save*` / `load*` / `clear*`
- [ ] `packages/core/src/config/schema.ts`:
- `Config` 接口加新字段(如 `fileAccessToken``accessTokenEnv`)
- `ConfigFile` 接口加对应 disk 字段(snake_case)
- [ ] `packages/core/src/config/loader.ts`:
- `loadConfig()` 把 env / 文件读到 Config 上
### B. core 客户端
- [ ] `packages/core/src/client/http.ts`:
- 不同 `credential.method` 走不同分支(参考已有 `access-token` 分支走 console gateway)
- Authorization 头注入正确
### C. cli 层
- [ ] `packages/cli/src/utils/ensure-key.ts`:
- 启动时检查新凭证方式是否已配置,缺的话提示
- 如果是交互式 setup(类似 `bl auth login --console`),增加新分支
- [ ] `packages/cli/src/commands/auth/login.ts`:
- 新增 `--xxx` flag 触发新登录流程
- 持久化到 config(调用 core 的 save 函数)
- [ ] `packages/cli/src/commands/auth/status.ts`:
- 分别显示 `api_key` / `access_token` 是否已配置,以及 DashScope vs 控制台网关各自生效的 credential
- [ ] `packages/cli/src/output/status-bar.ts`:
- 顶部状态条显示新凭证 method
### D. main 启动逻辑
- [ ] `packages/cli/src/main.ts:NO_AUTH_SETUP` 列表:
- 如果新增的命令"自己管鉴权或不需要鉴权",加进去绕开 ensureApiKey 拦截
- 当前清单以 `main.ts:NO_AUTH_SETUP` 为准
### E. 错误文案
- [ ] core 的 `BailianError` 鉴权失败 hint **保持通用**(不写 cli 命令名,见 [error-hint-change.md](error-hint-change.md))
- [ ] cli 的 `enhanceHint` (error-handler.ts) 按 `ExitCode.AUTH` 注入新方式的 cli 命令引导
### F. 用户面文档
- [ ] `README.md` / `README_CN.md` "Authentication" 段落
### G. 测试
- [ ] `packages/cli/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径
- [ ] mask token 的输出格式不变(避免泄漏)
## 完成后自查
```sh
# 各种凭证组合
unset DASHSCOPE_API_KEY DASHSCOPE_ACCESS_TOKEN
HOME=/tmp/empty node packages/cli/src/main.ts auth status
# flag 注入
node packages/cli/src/main.ts auth status --api-key sk-xxx
# env 注入
DASHSCOPE_ACCESS_TOKEN=xxx node packages/cli/src/main.ts auth status
```
## 常见漏点
- ✗ 加了新 token 来源但忘了改 `resolveCredential` 优先级,实际不生效
-`Config` 加字段但 `loadConfig` 没读 → 字段永远 undefined
-`bl auth login` 写成功但 `bl auth status` 不识别(两边走的 storage path 不一致)
- ✗ token mask 显示完整 token,日志泄漏
+104
View File
@@ -0,0 +1,104 @@
# 分支合并 Review
## 触发条件
- 评估某分支(feature / pipeline / 重构分支)能否合到 `main`
- 评估合并后对原有功能的侵入性影响
- 用户问"X 分支可以合 Y 吗 / 有什么影响"
## 目标
- **不破坏原功能**:共享文件的运行时行为、公共类型、构建配置不能静默变化
- **新功能可发现**:用户可见的新命令/新 flag 必须有文档和示例
## 步骤(按顺序)
### ① 看分歧
```sh
git fetch origin <base>
git log --oneline <base>..<head> # head 比 base 多的提交
git log --oneline <head>..<base> # base 比 head 多的提交(双向都看,base 已大幅领先时尤其重要)
```
### ② 干跑合并,先确认有无冲突
```sh
git merge-tree $(git merge-base <base> <head>) <base> <head> > /tmp/merge.txt
echo "exit=$?"
grep -E "^(<<<<<<<|>>>>>>>|CONFLICT)" /tmp/merge.txt | head -20
```
- exit=0 且无 `<<<<<<<` → 机器可合,继续 ③
- 有冲突 → 先列冲突文件,把方案讲清楚再动手
### ③ 拆 diff:共享文件 vs 新增文件
```sh
git diff --stat <base>...<head>
git diff --name-only <base>...<head>
```
- **新增文件**(对方分支没有)→ 侵入性 = 0,只看是否需要文档透出(跳到清单 B)
- **共享文件**(两边都有)→ 重点看,逐个跑 `git diff <base>...<head> -- <file>`,过清单 A
## 清单 A:侵入性(共享文件必看)
- [ ] **运行时行为没静默变化**:默认值、错误码 / `ExitCode`、retry 次数、并发度、超时
- [ ] **公共类型 / 导出签名向后兼容**:新增可选字段 OK;改必填、删字段、改返回类型 → 不行(参考 [packages/core/src/types/](packages/core/src/types/))
- [ ] **`pnpm-workspace.yaml` 没收窄通配**:`packages/*` 改成显式列表会漏掉目标分支新增的子包(本次 pipeline → main 踩过这个坑,漏了 `packages/skills`)
- [ ] **`package.json` 没破坏发布元数据**:`bin` / `exports` / `files` / `inlinedDependencies` 字段任何删除或改名都要单独评估
- [ ] **公共依赖没被悄悄升级**:catalog / 根 lockfile 改动要列出来
- [ ] **`package.json` version 没倒退**:目标分支已经更高时(如 main 1.0.3 vs head 1.0.0-beta.1),手动对齐版本号,不要被 head 覆盖
- [ ] **全局表没冲突**:`registry.ts``NO_AUTH_SETUP`(`packages/cli/src/main.ts`)、`ExitCode` 三个全局表新增项不和现有项冲突
## 清单 B:用户透出(用户可见的新东西必看)
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
- [README.md](README.md) + [README_CN.md](README_CN.md)(中英文都要,常漏 `_CN`)
- (SKILL.md 已迁出本仓库,由 `npx add skills` 机制独立维护,不在本仓库 review 范围)
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` / `apiDocs` 都填了
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例(参考 [packages/cli/scene/](packages/cli/scene/) 的组织方式)
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
- [ ] **错误信息 / 提示文案**:面向用户的字符串通顺、双语(项目主体是中文场景)
## 清单 C:容易漏的(每条一行扫一眼)
- [ ] **改了文件但没补测试**:`git diff --stat <base>...<head> -- '*test*' '*spec*'` 与改动文件清单对照
- [ ] **新功能埋点同步**:遥测事件名 + 参数 allowlist(参考 main 上的 `feat(telemetry): track console gateway api name in params allowlist` commit)
- [ ] **环境变量**:新增 / 重命名的 env var 进 README,旧的有没有兼容
- [ ] **i18n**:`README.md` 改了,`README_CN.md` 同步了吗
## 输出报告(照模板填)
```
冲突: 无 / 有 → <文件列表>
必须修(合并前在 head 分支上 commit 掉):
- <清单项> + <文件:行号> + <一句话原因>
↑ 只放真正"head 分支自己写错了"的项,例如 pnpm-workspace.yaml 收窄、version 倒退、
删了不该删的字段等。这些 fix 应该作为 head 分支上的新 commit,而不是合并解冲突时顺手处理。
解冲突要点(merge 时不要漏):
- <冲突文件> + <字段/段落> + <怎么取舍>
↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、
NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
建议修(可后置):
- ...
仅信息(无需动作,告知即可):
- ...
合并姿势:
1. 在 head 分支上修上面"必须修"的项,提 commit
2. 合并 main,按"解冲突要点"逐项处理冲突
3. <pnpm install / 测试 / 构建命令>
4. 提 MR 合 main
```
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `pnpm-workspace.yaml``packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README_CN](README_CN.md) | 用户完全感知不到新功能 |
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API |
| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
+186
View File
@@ -0,0 +1,186 @@
# 撰写 Change Log
## 触发条件
- 发布新版本(beta / rc / stable)后,需要为这个版本写一份 release notes
- 补写历史版本遗漏的 changelog
## 发布位置
更新仓库内的两份文件,英文优先,中文同步:
- [`CHANGELOG.md`](../../CHANGELOG.md)
- [`CHANGELOG_CN.md`](../../CHANGELOG_CN.md)
新版本条目插在文件顶部"## [X.Y.Z] - YYYY-MM-DD"位置,旧版本依次向下保留。两份文件保持一一对应——任何条目只在一份里出现,另一份漏写,视为错误。
## 输出格式:Keep a Changelog
本项目采用 [Keep a Changelog](https://keepachangelog.com/) 规范,标准 6 段(按出现顺序):
| 段名 | 含义 | 示例 |
| ------------ | ----------------------------- | --------------------------------- |
| `Added` | 新增功能 / 命令 / 参数 | 新增 `bl app list` |
| `Changed` | 已有功能行为/形式变化(非破坏) | `--page-num` 改名 `--page` |
| `Deprecated` | 即将移除但当前仍可用 | `--legacy-flag` 弃用,下个版本移除 |
| `Removed` | 本版本删除的能力 | 移除 `--instructions` |
| `Fixed` | Bug 修复 | 修复视频生成模型错误 |
| `Security` | 安全相关修复 | 修复凭据明文落盘 |
项目扩展段:`Internal`(放研发体系 / 工具链 / 测试基建,与用户无关但值得记录的工程改动)。
某段没内容就**直接省略**,不要写"无"。
## 撰写步骤
### 1. 确定版本边界
通过 `package.json` 的版本号变更找出两个版本的 commit 边界:
```sh
# 找出 packages/cli/package.json 历次版本变更
git log --all --oneline --pretty=format:'%h %ad %s' --date=short \
-G '"version"' -- packages/cli/package.json | head
```
记下:
- `prevBumpCommit` — 上一个版本号 bump commit(如 `1.0.0` 的 commit)
- `currBumpCommit` — 当前版本号 bump commit(如 `1.0.1` 的 commit)
本版本内容 = `git log prevBumpCommit..currBumpCommit`
### 2. 列出本版本所有 commit
```sh
git log <prevBumpCommit>..<currBumpCommit> --no-merges \
--pretty=format:'%h %ad %s' --date=short
```
> 注意:用 `--no-merges` 过滤 merge commit,避免重复条目。
### 3. 逐条核对 commit 是否真的进了本版本
merge 顺序复杂时,commit 标题在但内容未必合入。用 `git merge-base --is-ancestor` 严格验证:
```sh
git merge-base --is-ancestor <featureCommit> <releaseCommit> \
&& echo "IN" || echo "NOT IN"
```
例:验证 `agent chat`(commit `12f2b1b`)是否在 `1.0.0`(commit `3fc54ae`)里:
```sh
git merge-base --is-ancestor 12f2b1b 3fc54ae && echo "IN" || echo "NOT IN"
```
**不要凭 commit 标题猜**——分支模型常导致一个功能开发完成但未合入当前 release。
### 4. 在 release commit 上抽样校验代码真实存在
光看 commit 还不够,要确认目标功能的代码 / 文件在 release commit 上真的存在:
```sh
# 列出 release commit 下某目录的文件
git ls-tree -r <releaseCommit> --name-only -- packages/cli/src/commands/
# 看 release commit 下某文件的内容
git show <releaseCommit>:packages/cli/src/commands/console/call.ts | head
```
特别注意被一行带过的"杂项" commit。本仓库历史踩过坑:`feat(cli): enhance output options and add new commands` 这种标题里藏了**新命令** + **新输出格式** + **logout 增强**三件事,粗看会全部漏掉。
```sh
# 看整个 commit 改了哪些文件、新增了多少行
git show <commit> --stat
```
只要 `--stat` 里出现新文件或大块新增,就值得展开看。
### 5. 区分 Added vs Changed
- **Added**:新文件、新命令、新参数、新输出格式 → 用户能"用上一个新东西"
- **Changed**:已有功能改名、改默认值、改交互文案、性能优化、参数命名统一 → 用户"原来就在用的东西变样了"
判断方法:在 `prevBumpCommit``git show <prevBumpCommit>:<file>` 看这个文件 / 函数原来在不在。
### 6. 排除"不该写进去"的内容
| 不要写 | 原因 |
| --------------------------------------------------- | ---------------------------------- |
| 未发布 / 仅在分支上的功能 | release commit 不包含 → 用户拿不到 |
| 仅在文档 / 设计稿层面的能力 | 没代码就没"功能",不能写进 Added |
| 内部包 / 私有工具的开发过程 commit | 用户不可见 |
| 仓库内 file path / 模块路径(如 `core/telemetry/`) | 用户感知不到内部结构 |
| 临时调试 commit(remove console / 补 TODO 等) | 噪音 |
### 7. 标记 Breaking Change
- 单条:在条目末尾加 `**(BREAKING)**` 或行内提示
- 多条:版本头单独开 `### Breaking Changes` 段(major 升级才允许)
判断标准:用户**已有的命令 / 脚本 / 集成会因为升级而坏掉**。常见来源:
- 命令重命名 / 删除
- 参数重命名 / 删除
- 默认值变化导致输出格式变化
- 包结构 / import 路径变化(monorepo 拆包)
- 配置文件 schema 不兼容
### 8. 写完后给用户过一遍再写入文件
**不要直接编辑 `CHANGELOG.md` / `CHANGELOG_CN.md`**。先把中英两份草稿都贴回对话里,让用户:
- 增删条目
- 调整措辞(中英、术语)
- 确认 BREAKING 标记
- 确认版本边界假设是否成立
用户确认后,把新版本块插入两个文件顶部(在 H1 标题与上一个版本块之间)。
## 模板
```markdown
## [X.Y.Z] - YYYY-MM-DD
> 一句话概括本版本主线(可省略,大版本/含 breaking 时建议加)
### Added
- **<能力名>**:一句话描述用户能做什么
- 子项 1
- 子项 2
### Changed
- **<点名>(BREAKING)**:变化前 → 变化后,影响范围
### Fixed
- 修复 X 在 Y 场景下的问题
### Internal
- 工程类改动(不影响用户行为)
```
## 常见漏点(基于真实踩坑)
| 漏点 | 后果 |
| ------------------------------------------------- | ------------------------------------------------------- |
| 只看 commit 标题不看 `--stat` | "enhance output options" 这种笼统标题里藏的新命令被漏掉 |
| 凭 commit 标题判断是否进了 release | 分支没合入,标题在但代码不在 |
| 把分支上 WIP 当作已发布功能 | 用户升级后找不到对应能力,被投诉 |
| 把内部文件路径写进 changelog | 用户看不懂,且暴露内部结构 |
| 优化类改动错放到 Added | 用户以为是新功能去找,找不到入口 |
| 版本号 bump commit 自身的 README 改动算进上个版本 | 重复 / 错位 |
| 中英两份不同步 | 文档可信度直接崩,等同于撒谎 |
## 与 release.md 的边界
| 文档 | 管什么 |
| ------------------------ | ---------------------------------------------------- |
| [release.md](release.md) | 发版前自检:版本号 / 包内容 / 安全扫描 / publish 流程 |
| 本文档 | 发版后写说明:面向用户的 release notes |
两者顺序:`release.md` → npm publish → 本文档(更新 `CHANGELOG.md` + `CHANGELOG_CN.md`)→ 推到 GitHub。
+100
View File
@@ -0,0 +1,100 @@
# CLI E2E 测试规范
## 触发条件
- 新增/修改 `packages/cli/src` 下的 command`commands/catalog.ts` 登记、`defineCommand` 实现、options/usage
- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例
- 为命令补 help / 缺参 / dry-run / 真实集成测试
以上情况必须同步维护 `packages/cli/tests/e2e/<topic>.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`
## 文件与工具
- 路径:`packages/cli/tests/e2e/<kebab-topic>.e2e.test.ts`
- 框架:`vite-plus/test`;子进程跑 CLI`runCli` from `./helpers.ts`
- 解析 JSON stdout`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))`
- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时
## 双层 describe固定结构
```ts
// 1) 不 skip分组 + --help无密钥、无真实 API
describe("e2e: <topic>", () => {
test("<group> 分组展示子命令帮助且成功退出", ...);
test("<subcommand> --help 正常退出", ...);
});
// 2) skipIf缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑
describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
test("缺少 --<flag> 时退出为用法错误 (2)", ...);
test("<cmd> --dry-run ...", ...); // 若适用
test("【model】真实流程", ..., LONG_TIMEOUT);
});
```
## skip 条件helpers.ts
| 场景 | 条件 |
| ------------------- | ----------------------------------------------------- |
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| 知识库 | `isKnowledgeE2EReady()` |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
## 用例类型
1. **分组 help**`runCli(["image"])``exitCode === 0`stdout+stderr 含子命令名
2. **--help**`runCli([..., "--help"])` → stderr 含主要 flags
3. **缺参**`--non-interactive` 且不传 required flag → `exitCode === 2`stderr 匹配 `--flag|Missing required argument`
4. **--dry-run**:仅当实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本,不入网
5. **真实集成**:保留既有用例名称与断言;放在 skip 块**末尾**
## 安全与例外
- **禁止真实破坏性操作**`auth logout` 只用 `--dry-run``config set` 只用 `--dry-run`
- **不加 dry-run**`dryRun``resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit``speech recognize``--url`
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag缺参用例勿带该 flag
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
## 新增 command 检查清单
- [ ] `commands/catalog.ts` 登记 + `tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
- [ ] 若改了 `usage` / `options` / `examples`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/`(本仓库 gitignore)
- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
- [ ] 至少一条真实集成(或说明为何仅 smoke不破坏已有集成用例顺序
- [ ] `pnpm test packages/cli/tests/e2e/<file>` 通过
## 示例片段
```ts
test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["foo", "bar", "--non-interactive"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Missing required argument/i);
});
test("foo bar --dry-run 仅输出计划", async () => {
const { stdout, stderr, exitCode } = await runCli([
"foo",
"bar",
"--dry-run",
"--prompt",
"x",
"--non-interactive",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: unknown }>(stdout);
expect(data.request).toBeDefined();
});
```
## 与批量压测的关系
- **E2E**:单条/少量调用、断言固定、可进 `vp test`(见上文 skip 条件)
- **批量压测**`packages/cli/tests/stress/run.mjs` + `targets/*.mjs`,并发 + 报告,**仅手动** `pnpm run test:stress -- <target>`
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md)。
+94
View File
@@ -0,0 +1,94 @@
# 命令增删改
## 触发条件
- 增加新的 `bl xxx` 命令
- 删除已有命令
- 重命名命令(包括从单级 `bl x` 改成 `bl x y` 或反向)
## 命令路径与文件路径的对应规则
```
单级命令(无 group): commands/<name>.ts ↔ bl <name>
例: commands/update.ts ↔ bl update
两级命令(有 group): commands/<group>/<action>.ts ↔ bl <group> <action>
例: commands/text/chat.ts ↔ bl text chat
三级命令(子组,慎用): commands/<group>/<sub>/<action>.ts ↔ bl <group> <sub> <action>
例: commands/memory/profile/create.ts ↔ bl memory profile create
仅当子组下有 ≥2 个 action 时合理(否则拍平到两级)
```
文件路径与命令路径必须 1:1 对齐。
## CLI 命令注册架构(必读)
命令元数据以 **`catalog.ts` 为单一登记处**;`registry.ts` 只负责解析与打印 help,不再内嵌命令表或手写 Resources 列表。
```
commands/<...>.ts defineCommand({ name, description, usage, options, examples, apiDocs?, run })
commands/catalog.ts export const commands: Record<string, Command>
┌────┴────┬──────────────────────┬─────────────────────┐
↓ ↓ ↓ ↓
registry.ts main.ts tools/generate-reference.ts export-schema.ts
(解析/help) (入口) → tools/generated/reference/index.md + <group>.md
```
- **`packages/cli/src/commands/catalog.ts`**: `import` 命令模块 + `"<path>": handler` 映射;**不** `import registry.ts`(避免构建时循环依赖)
- **`packages/cli/src/commands/index.ts`**: `export { commands } from "./catalog.ts"`(给包内 re-export 用)
- **`packages/cli/src/registry.ts`**: `import { commands } from "./commands/catalog.ts"`,建树、`resolve``printHelp`;Commands / Global Flags 从 `Command` 元数据与 `GLOBAL_OPTIONS` **动态生成**
- **`tools/generate-reference.ts`**: build 前读 `catalog.ts`,写 `tools/generated/reference/index.md`(索引) + `tools/generated/reference/<一级命令>.md`(详情,勿手改)。该目录被 gitignore,产物供未来的 `npx add skills` 安装机制消费
已删除、勿再引用:`commands/help.ts``registry.ts` 内联 `new CommandRegistry({...})``printRootHelp` 手写命令行。
## 必查清单
### A. 代码层
- [ ] **新建/删除/移动**对应的 `packages/cli/src/commands/<...>.ts` 文件
- [ ] **`packages/cli/src/commands/catalog.ts`**:
- 增删 `import xxx from "./.../xxx.ts"`
-`export const commands` 里增删 `"<group> <action>": xxx`(key 与 `defineCommand({ name })` 一致)
- [ ] **不要**在 `registry.ts` 里重复登记命令(已从 catalog 读取)
- [ ] 命令需在 `bl help` / `reference/` 展示 API 文档链接时,在 `defineCommand` 里设 `apiDocs`(相对路径);help 与 reference 均从此字段生成
- [ ] 如果命令需要鉴权之外的特殊路径,看 `packages/cli/src/main.ts``NO_AUTH_SETUP`
- [ ] **`config/export-schema.ts`**: 若新命令不适合作为 agent tool,评估是否加入 `SKIP_PREFIXES`;该文件在 `run()``import("../catalog.ts")`,勿顶层 import catalog 以免循环依赖
### B. 文档层
- [ ] 运行 `pnpm --filter bailian-cli run generate:reference`(或 `build`),刷新 `tools/generated/reference/` 下生成文件(本仓库 gitignore,仅供本地校验和未来 skill 安装机制消费)
- [ ] `README.md` / `README_CN.md`: Quick Start、命令一览(用户向,与 help 对齐即可)
- [ ] SKILL.md 已搬出本仓库(由 `npx add skills` 机制分发),本仓库不再维护
### C. 测试层
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/<topic>.e2e.test.ts`
- [ ] 删除命令时一并删对应 e2e
### D. 重命名特殊处理
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
- `catalog.ts` 的 key
- error hints(cli 层)
- `tools/generated/reference/`(重建后检查;本仓库 gitignore)
- README 示例
- 测试断言
## 完成后自查
```sh
pnpm --filter bailian-cli run generate:reference # reference/ 与 catalog 一致
node packages/cli/src/main.ts <new-command> --help
node packages/cli/src/main.ts # 根 help 列表含新命令
vp test packages/cli/tests/e2e/<topic>.e2e.test.ts # 相关 e2e
```
## 常见漏点
- ✗ 只改了命令文件,忘了 **`catalog.ts`** → 命令不存在或 help 里没有
- ✗ 手改 **`tools/generated/reference/*.md`** → 下次 build 被覆盖;应改 `defineCommand` 后重新 generate
- ✗ 在 `export-schema.ts` 顶层 `import catalog` → 可能与 registry 循环依赖
- ✗ 单 action 的子组是反模式,新增时优先拍平为两级
+56
View File
@@ -0,0 +1,56 @@
# 命令选项变更
## 触发条件
- 给已有命令新增 `--flag <value>`
- 改 flag 默认值
- 删除 / 重命名已有 flag
- 把 flag 从可选变成必填(或反向)
## 必查清单
### A. 命令文件本身
- [ ] `packages/cli/src/commands/<group>/<action>.ts`:
- `defineCommand({ options: [...] })` 数组里增删/改 `{ flag, description, type, required }`
- `usage` 字段(如 `"bl text chat --message <text> [flags]"`)反映新签名
- `examples` 数组覆盖新 flag 至少一个示例
- `run()` 里读取 flag 的代码:
- 类型转换正确(`type: "number"``flags.x as number`,`"array"``as string[]`)
- 必填校验:`if (!flags.x) failIfMissing("x", ...)` 或交互式 prompt
- 默认值 fallback
### B. 鉴权 / 全局选项
- [ ] 如果是**全局 flag**(所有命令通用),改 `packages/core/src/types/command.ts``GLOBAL_OPTIONS`
- [ ] 如果新 flag 影响 `Config`,改 `packages/core/src/config/schema.ts``Config` 接口
- [ ] 如果对应 env var,改 `packages/core/src/config/loader.ts``loadConfig`
### C. 文档层
- [ ] `README.md` / `README_CN.md` 如果在示例里展示了相关命令,补充新 flag
- [ ]`pnpm --filter bailian-cli run generate:reference`,让 `tools/generated/reference/` 与命令一致(本仓库 gitignore,勿手改;SKILL.md 已迁出本仓库)
### D. 测试层
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 在 `packages/cli/tests/e2e/<command>.e2e.test.ts` 增加新 flag 的断言(含缺参、`--help`、dry-run 若适用)
- [ ] 删除 flag 时,清掉相关测试用例
### E. 重命名特殊处理
- [ ] 全仓 grep 旧 flag 名(包括 `--old-name``oldName``old_name` 三种形态,因为 args.ts 会做 kebab→camel 转换)
- [ ] 必要时保留**deprecated alias**(老 flag 仍可用,但 stderr 警告 → 下版本删)
## 完成后自查
```sh
node packages/cli/src/main.ts <command> --help # 看新 flag 出现在 Options
node packages/cli/src/main.ts <command> --new-flag x # 实测一遍
```
## 常见漏点
- ✗ 加 `type: "number"``String(flags.x)` 触发 lint 警告(参考已修过的 memory/list.ts)
- ✗ 加了 array 型 flag 但没考虑用户可能传多次
- ✗ 改默认值忘记更新 description 里的 "(default: xxx)" 文案
- ✗ Required flag 缺失时直接抛硬错而不是 prompt(交互友好性问题,参考已实现 prompt 的命令文件作为示例)
+84
View File
@@ -0,0 +1,84 @@
# 配置项扩展
## 触发条件
- 新增 env var(如 `DASHSCOPE_*` / `BAILIAN_*` / `NO_COLOR`)
-`~/.bailian/config.json` 加字段
- 给全局 flag 加新选项(`--xxx`)
- 改 config 字段优先级
## 配置三层来源
```
flag (--xxx) ─┐
├─ loadConfig() 合并 ─→ Config(运行时单一对象)
env (XXX=yyy) ─┤
config 文件 ─┘
~/.bailian/config.json
```
优先级一般是 **flag > env > config 文件 > 默认值**,具体见 `core/config/loader.ts`
## 必查清单
### A. 类型定义
- [ ] `packages/core/src/config/schema.ts`:
- `Config`(运行时形状)加新字段
- `ConfigFile`(disk 形状,snake_case)加新字段(如果允许写文件)
- `parseConfigFile()` 解析新字段
- 如果是 enum 字段,加校验
### B. 加载逻辑
- [ ] `packages/core/src/config/loader.ts:loadConfig()`:
- 加新字段的合并逻辑(`flags.x ?? process.env.XXX ?? file.x ?? default`)
- 校验(数值范围、枚举合法性等)
- 校验失败抛 `BailianError(USAGE)`
### C. 全局 flag(如果加的是 flag)
- [ ] `packages/core/src/types/command.ts:GLOBAL_OPTIONS` 数组
- [ ] `registry.ts``buildGlobalFlagLines` 会**自动**从 `GLOBAL_OPTIONS` 生成 `bl --help``reference/index.md` 的全局 flag 段,无需手写
- [ ] flag 的 type 标注(`boolean` / `number` / `array`),让 args.ts 正确解析
- [ ] 改完全局 flag 后跑 `pnpm --filter bailian-cli run generate:reference`
### D. 命令使用方
- [ ] 用到新字段的命令文件直接读 `config.xxx`,不要重复解析
- [ ] 配置展示 / 修改命令同步:
- `packages/cli/src/commands/config/show.ts` 显示新字段
- `packages/cli/src/commands/config/set.ts` 允许 set
- `packages/cli/src/commands/config/export-schema.ts` 在 schema 输出里
### E. 文档
- [ ] `README.md` / `README_CN.md` 的 env var 表格
### F. 测试
- [ ] 单测覆盖优先级:flag > env > file
- [ ] 校验失败抛错(非法值)
- [ ] 默认值正确
## 完成后自查
```sh
# 三个来源都试一遍
node packages/cli/src/main.ts config show --output json | grep <new-field>
XXX=value node packages/cli/src/main.ts config show --output json | grep <new-field>
node packages/cli/src/main.ts config show --xxx value --output json | grep <new-field>
# 写到文件
node packages/cli/src/main.ts config set --key <key> --value <value>
cat ~/.bailian/config.json
```
## 常见漏点
-`Config` 接口加字段但 `loadConfig` 没填,运行时永远 undefined
-`ConfigFile` 用 camelCase 字段名(disk schema 应该是 snake_case)
- ✗ 全局 flag 没标 `type: "boolean"`,被当成需要值的 `--xxx <value>`
- ✗ 加了 env var 但 README 表格没更新,用户不知道有这条
-`config show` 不显示新字段,用户改了无法回查
+125
View File
@@ -0,0 +1,125 @@
# 错误文案变更
## 触发条件
- 修改 `BailianError` 的 message 或 hint
- 调整 cli 的 hint 增强逻辑(`enhanceHint`)
- 改 ensure-key 的 setup 流程文案
- 改任何抛错位置的分类(exitCode)
> 注意:`mapApiError` **不再做错误分类**(参见下方"边界原则")。如果你想给某种 HTTP 错误码加白名单分类,请先回到本文档读完"边界原则"再说。
## 边界原则:CLI 不翻译服务端错误
**CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的错误原样透传。**
| 错误来源 | 归类 | 处理方式 |
| ---------------------------------------------------- | -------- | ----------------------------------------------------------- |
| 命令解析、缺 flag、参数校验 | **内部** | `BailianError(USAGE)` |
| 文件 I/O(ENOENT/EACCES/...) | **内部** | `BailianError(GENERAL)` + errno-specific hint |
| 本地 credentials 缺失(resolver/ensure-key/AK-SK 等) | **内部** | `BailianError(AUTH)` |
| `fetch` 自身失败(DNS/TCP/TLS/proxy) | **内部** | `BailianError(NETWORK)` + 读 `err.cause.code` 给 errno-hint |
| polling 客户端超时 | **内部** | `BailianError(TIMEOUT)` |
| HTTP 4xx/5xx、HTTP 200 + 业务错码、async task FAILED | **服务** | `BailianError(GENERAL)`,**message 原样透传**,不分类、不替换 |
**判断标准**:错误信息的"权威来源"在哪一侧 —— 来自服务端响应 → 服务错误,透传;来自本地(OS / Node / CLI 自己)→ 内部错误,包装。
### 反面 case(为什么这条原则是必要的)
历史上的几个错误处理 bug 都是因为越过了这条边界:
1. **`mapApiError` 白名单未覆盖 OpenAI 兼容错误码** —— `qwen3.7` 不存在时服务端返回 `invalid_request_error`,白名单只认 `ModelNotFound`,fall through 到 GENERAL,该是 USAGE 信号被丢
2. **`bl auth login` 用 try/catch 替换错误** —— key 有效但缺模型权限时,显示"API key validation failed"撒谎,误导用户去换 key
3. **`error-handler.ts` 用 message 关键词归并网络错误** —— ENOTFOUND / ECONNREFUSED / TLS 错误全归并成"Network request failed.",错误根源完全丢失
共同根因:**我们试图在没有"权威信息"的位置代理服务端做分类**。修复方案是统一退回到透传 + 让本地错误的真实诊断浮上来。
## 错误流的分层架构
```
core 抛出 BailianError(message, exitCode, hint, cause?)
↓ 沿调用栈冒泡
cli/main.ts: main().catch(handleError)
cli/error-handler.ts:
- 服务端错误(BailianError(GENERAL)) → text 直接打 message
- 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强)
- TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK
- Node fs errno → 翻成 GENERAL + errno hint
- 其它 Error → 默认走 cause 链
process.exit(err.exitCode)
```
## 不变量(必须遵守)
### 1. 不替换服务端错误的 message
-`try { call() } catch { throw new BailianError("xxx failed", FIXED_CODE) }`
-`try { call() } catch (err) { /* 加上下文不替换 */ throw err; }` 或直接不 catch
### 2. 不在 `mapApiError` 里加 status / apiCode 白名单分支
- ❌ 不要回退到"401 → AUTH、429 → QUOTA"那套白名单
- ✅ message 把 status / apiCode / request_id 拼进去就够,exit 统一 GENERAL
- 例外:CLI **自己**因为本地状态产生的 BailianError(resolver、ensure-key 等)可以用语义化 exitCode
### 3. core 的 hint 必须不含 cli 关切
- ❌ 不写 `bl xxx` 命令名
- ❌ 不写控制台 URL 或 region
- ❌ 不写渠道追踪参数(`source_channel=xxx`)
- ✅ 只描述抽象做法(如 `"Set DASHSCOPE_API_KEY environment variable, or pass --api-key."`)
### 4. cli 端可以自由使用 cli 命令名 + URL
- 命令文件、`error-handler.ts``utils/ensure-key.ts` 是 cli 层,内部可以写 `bl xxx`
- URL 必须从 `packages/cli/src/urls.ts` import,不能硬编码
## 必查清单
### A. core 改动(message / hint)
- [ ] `packages/core/src/errors/api.ts``mapApiError`:**保持透传形态**,不要加白名单分支
- [ ] `packages/core/src/auth/resolver.ts` 改 throw 语句:hint 不含 cli 关切
- [ ] 任何 core 文件 throw 的 BailianError:同上
### B. cli 增强(`enhanceHint`)
- [ ] `packages/cli/src/error-handler.ts:enhanceHint`:**当前只为 internal AUTH 增强**(因为只有 resolver/ensure-key 等内部位置会发 AUTH)
- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"`
### C. cli 直接抛错(`ensure-key`、命令文件)
- [ ] cli 层抛 BailianError 时,hint 里可以放 cli 命令名,但 **URL 一律走 `urls.ts` import**
- [ ] 抛错位置如果**已经在调用服务端**,catch 时不要替换 message——重新评估是否需要 catch
### D. 文案一致性
- [ ] 服务端错误的 message:必含 `HTTP <status>` 字段;有 apiCode/request_id 也拼上
- [ ] 网络层错误的 message:必含 `err.cause.code`(如 ENOTFOUND)
- [ ] 中英文混用慎重 —— 当前主要是英文文案
## 完成后自查
```sh
# 触发对应错误,看 text 输出
HOME=/tmp/empty node packages/cli/src/main.ts text chat --message "x" --non-interactive
# 看 JSON 输出(应包含 cause 字段当 cause 存在时)
HOME=/tmp/empty node packages/cli/src/main.ts text chat --message "x" --non-interactive --output json
# 模拟网络层错误,验证 errno 透传
DASHSCOPE_BASE_URL=https://nonexistent-host.invalid \
node packages/cli/src/main.ts text chat --message hi
# 预期:"Network request failed: ENOTFOUND ..." + Caused by 链
```
text 模式应看到准确诊断,JSON 模式应能解析出 cause.code(用于 agent 决策)。
## 常见漏点
- ✗ 在 core 的 hint 里"顺便"写了 `bl auth login` —— 违反第 3 条不变量
- ✗ 给 `mapApiError` 加了"401 → AUTH"等白名单分支 —— 违反边界原则
- ✗ 用 `try/catch` 包住服务调用,catch 里 throw 了一个新的 BailianError 替换原错误 —— 违反第 1 条不变量
- ✗ 加新 ExitCode 但没想清楚谁产生它 —— 服务端永远不应产生新 ExitCode,内部错误才需要新分类
+65
View File
@@ -0,0 +1,65 @@
# 工具链调整
## 触发条件
- 升级 Vite+ / TypeScript / Node 版本
- 调整 `vite.config.ts`(根 / 各包)
- 改 lint 规则(Oxlint / Oxfmt / typescript-eslint)
- 升级或替换依赖
- 修改 `.vite-hooks/` 或 git hooks
## 必查清单
### A. 版本一致性
- [ ] `package.json``engines.node` 与 README 的 Node.js 徽章一致
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
- [ ] 三处 `tsconfig.json`(根 + cli + core)的 target / module 设置一致
### B. lint / format 规则改动
- [ ] 全仓跑 `vp check --fix`,看是否产生大量自动 reformat
- [ ] 如果产生 mass diff,**单独提一个 commit**(代码语义改动和 lint reformat 不要混)
- [ ] 已有 warning 的处理:
- 如果新规则消除了某些旧 warning,确认是否合理
- 如果新规则产生了新 warning,评估是否要修
### C. 构建配置
- [ ] `packages/cli/vite.config.ts``packages/core/vite.config.ts` 的 entry / external / dts 设置
- [ ] cli 的 bundle 必须把 `bailian-cli-core`**external**(不内联),确认 `dist/bailian.mjs` 第一行有 `from "bailian-cli-core"`
- [ ] cli 的 bundle 第一行必须有 `#!/usr/bin/env node` shebang(`tools/release.mjs check` 会断言)
### D. 依赖升级
- [ ] 检查 `bailian-cli-core` 在 cli 的 `dependencies` 里仍是 `"workspace:*"`(不要变成实际版本号 — `tools/release.mjs` 会拦)
- [ ] 升级后跑 `vp check && vp test`
- [ ] 升级 `@types/node` 时注意 Node API 变化(如 fs.existsSync 行为)
### E. git hooks / pre-commit
- [ ] `.vite-hooks/pre-commit` 改动后,`pnpm install` 重新软链(走 `prepare: vp config`)
- [ ] 增加 hook 时,确认在干净 clone 后能自动激活
### F. CI / 发版工具
- [ ] `tools/release.mjs` 中如有版本/规则相关的硬编码,同步更新
- [ ] 比如 `secretPatterns` 添加新的敏感值识别
## 完成后自查
```sh
# 完整冒烟
pnpm install --frozen-lockfile
vp check
vp test
node tools/release.mjs check
```
## 常见漏点
- ✗ 升级 Node engines 但忘了 README 徽章
- ✗ 改 lint 规则后没全仓 `--fix`,新人 PR 报红一片
- ✗ 改 cli 的 vite config 把 core 不小心打成 inline,bundle 体积暴涨
- ✗ Oxlint 配置改了但 IDE 缓存还是旧的(IDE 可能要重启 ts server)
- ✗ 升级依赖一并升 lockfile,改动量大但没拆 commit
+130
View File
@@ -0,0 +1,130 @@
# 维护本指南
这份"AI 维护指南"(`AGENTS.md` + `docs/agents/*`)本身也是项目资产,需要随项目演化。
## 谁来维护
两类人/agent 会触发更新:
1. **AI agent 完成改动后**:回顾"这次改动是不是某个场景的常见漏点?清单是不是漏了什么?"
2. **人(开发者)发现新场景**:某次维护用不上 `docs/agents/` 现有任何场景文档,新增一份
## 何时更新
任何一种情况发生,这份指南就该改:
- ✅ 完成改动后发现"清单里没说,但实际要做"的步骤 → 补到对应场景的**必查清单**
- ✅ AI 漏了某个文件,review 时被指出 → 补到对应场景的**常见漏点**
- ✅ 一类改动反复出现且不属于任何已有场景 → 新建场景
- ✅ 项目结构调整(新增 `packages/xxx`、移动主要目录)→ 改 `AGENTS.md` 的**项目地图**
- ✅ 全局原则调整(如开始支持海外 region、加新的 core/cli 边界规则)→ 改 `AGENTS.md` 的**通用约定**
## 新增一份场景文档
### 何时新增 vs 扩展现有
- 已有场景 + 新清单项 → 直接加到对应 `.md`,**不要新建**
- 一类改动有自己**独立的触发条件 + 不重叠的清单** → 新建
判断:如果两个场景的清单重叠 ≥ 80%,说明是同一场景;新增清单项即可。
### 步骤
1.`docs/agents/` 下新建 `<scenario>.md`,文件名 kebab-case 描述场景
2. 用下方模板填充
3.`AGENTS.md` 的"业务场景索引"表格里加一行(按场景频率从高到低排序)
4. 提 PR 时附 1-2 个真实改动 commit 链接,说明这个场景已经发生过
### 文件模板
```markdown
# <场景中文标题>
## 触发条件
- 何时进入这份文档(2-4 条具体情况)
## 概念图(可选)
若场景涉及多文件协作,画一张简单的层次/数据流图
## 必查清单
### A. <分组名>
- [ ] 具体到文件路径的 action
- [ ] ...
### B. <分组名>
- [ ] ...
## 完成后自查
1-3 条可执行的验证命令
## 常见漏点
基于真实踩坑(初版可空,随实际场景生长)
```
### 命名约定
- **文件名**:`<topic>-<verb>.md`(`command-add-remove.md``url-change.md``config-add.md`)
- **场景标题**:3-6 字中文短语(命令增删改、URL / 渠道变更)
- **必查清单分组**:用 `### A. xxx` `### B. xxx` 字母编号,方便引用
### 跨场景引用
两份文档有共同规则时,**一处定义、其他引用**:
```markdown
<!-- error-hint-change.md 是定义方 -->
## 不变量
### 1. core 的 hint 必须不含 cli 关切
<!-- url-change.md 是引用方 -->
- ✗ 在 core 的 hint 里写 URL(违反 [error-hint-change.md](error-hint-change.md) 不变量 1)
```
避免同一规则在 N 份文档里复制粘贴,改一处全跟。
## 修改 AGENTS.md(主入口)
`AGENTS.md` 修改频率应该**远低于**场景文件,只在三种情况变动:
| 触发 | 改的段落 |
| ------------- | -------------- |
| 加/删一个场景 | 业务场景索引表 |
| 项目结构变化 | 项目地图 |
| 全局原则变化 | 通用约定 |
**不要把场景特定的清单往 AGENTS.md 塞** —— 它的设计目标是 AI 加载到上下文里 ~60 行就够,详细内容按需读 `docs/agents/`
## 文档应该是什么样
### Do
- 写清晰的 **must / must-not / 必查**,不写"建议"性语气
- 用 file path + 具体 action 的句式(`packages/cli/src/commands/catalog.ts:增加 import 与 commands 条目`)
- 在每份场景末尾留**常见漏点**段,持续累积真实经验
- 在跨场景的不变量上互相引用,不复制
### Don't
- ❌ 不写前置知识(假设读者会 TS / 会读代码)
- ❌ 不堆砌实现细节(代码改了文档也要改 — 不可持续;清单是"做什么",代码是"怎么做",后者交给代码)
- ❌ 不重复 `README.md` 的内容(README 面向用户,本指南面向维护者)
- ❌ 不写反向索引(文件 → 场景)— 双向维护成本高,AI 不需要
- ❌ 不要在场景文件里塞通用约定 — 通用的放 AGENTS.md
## 文档生长的节奏
这套文档**不是一次写完**,是随真实工作沉淀的:
- 初版只有触发条件 + 骨架清单 + 空"常见漏点"
- 每完成一次相关改动,补一两条清单或漏点
- 长期未触发的场景文件可以归并或删除(避免文档腐化)
+53
View File
@@ -0,0 +1,53 @@
# 模型上下架
## 触发条件
- 上线新的 Qwen / Wan / CosyVoice / 等模型
- 切换某命令的默认模型(如 `bl text chat` 默认从 qwen3.7-max 切到 qwen3.7-plus)
- 废弃旧模型
模型本身是阿里云后端在管,本仓库要做的是**让 CLI 能正确调用 + 文档/AI 入口准确反映可用模型清单**。
## 必查清单
### A. 命令实现
- [ ] `packages/cli/src/commands/<group>/<action>.ts`:
- `--model` flag 的 description 里"default:"反映新默认值
- 命令内部 `const model = (flags.model as string) || "<default>"` 的 fallback 字符串
- 如果命令维护一个 supported-models 列表(如 `speech/synthesize.ts:MODEL_VOICES`),增删条目
- 如果不同模型有不同 endpoint / 请求体形状,确保 `if (model.startsWith("xxx"))` 分支覆盖
- [ ] 模型如有特殊 endpoint,看 `packages/core/src/client/endpoints.ts`
### B. 类型层
- [ ] `packages/core/src/types/api.ts` 的 request/response 类型如果跟模型相关,同步字段
### C. 命令手册
- [ ]`--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `tools/generated/reference/<group>.md`(本仓库 gitignore;SKILL.md 由独立的 `npx add skills` 仓库维护,本仓库不再含)
### D. 用户面文档
- [ ] `README.md` / `README_CN.md`:
- Quick Start 示例如使用了具体型号,确认仍可用
- 顶部 introduction 段落如提到"Qwen-Omni"等品牌名,无需变(模型代号变化不算品牌变)
### E. 测试层
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 维护 e2e断言不硬编码废弃模型 ID新模型至少一条 happy-path 集成
## 完成后自查
```sh
# 默认模型走通
node packages/cli/src/main.ts <command> --message "test"
# 显式指定新模型
node packages/cli/src/main.ts <command> --model <new-model> --message "test"
```
## 常见漏点
- ✗ 改了命令默认模型,但 SKILL.md frontmatter 仍写老型号 → AI agent 调用时仍按老型号宣传
- ✗ 废弃模型时只删了代码,e2e 测试还在跑,CI 红
- ✗ 新模型 endpoint 不一致,但只改了 default,没加 endpoint 分支判断
+94
View File
@@ -0,0 +1,94 @@
# 发版前自检
## 触发条件
- 准备发布 beta / rc / 正式版到 npm
- 准备打 git tag
## 主入口:`tools/release.mjs`
发版流程**必须**走这两个命令,不要手动跑 `pnpm publish`:
```sh
node tools/release.mjs check # 全套自检,不发布
node tools/release.mjs publish # 自检 + 交互确认 + 发布
```
### `check` 已自动覆盖(无需手动重复)
| 检查项 | 实现 |
| -------------------------------------------------------------------------------------- | ------------------------------------------- |
| cli/core 版本号一致 | `validatePackages()` |
| `publishConfig.registry` 指向公共 npm | `assertPublishConfig()` |
| frozen lockfile install | `pnpm install --frozen-lockfile` |
| format + lint + type check | `pnpm run check` |
| 构建 core + cli | `pnpm --filter ... run build` |
| **黑名单文件**(`.env` / `.npmrc` / `*.pem` / `*.key` / `*.crt` / SSH keys / debug log) | `denyPathPatterns` in `scanPackageContents` |
| **敏感字符串**(DashScope `sk-xxxxx`、Alibaba `LTAI...`、access key secret) | `secretPatterns` in `scanPackageContents` |
| tarball 里 cli 依赖 `bailian-cli-core@<exact version>`,无 `workspace:*` 泄漏 | `assertCliPackage()` |
| cli bin 含 `#!/usr/bin/env node` shebang | 同上 |
| cli bundle 把 core 当外部依赖 | 同上 |
| core tarball 含 `dist/index.mjs` + `dist/index.d.mts` | `assertCorePackage()` |
如果新增了"应该自动检查"的项目级规则,优先加到 `tools/release.mjs`,不要单独靠人工或 AI 记。
## `release.mjs` 不覆盖的(手动确认)
### 版本号目标
- [ ] `packages/cli/package.json``packages/core/package.json` 已升到目标版本
- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`,**不要直接用 `1.0.0` 当 beta**)
### 用户面文档
- [ ] `README.md` / `README_CN.md` 的 Quick Start 命令仍能跑通
- [ ] README 的 Node.js 徽章版本与 `cli/package.json.engines.node` 一致
- [ ] README 宣传的 bin 名称在 `cli/package.json.bin` 都真的注册(常见漏点)
- [ ] `LICENSE` 文件存在(根 + cli + core 各一份)
### AI 入口资产
- [ ] `pnpm --filter bailian-cli run build` 已执行(`generate:reference` 会刷新 `tools/generated/reference/`,仅本地校验用,不随 npm 包发布)
- [ ] SKILL.md 与命令手册的分发已迁出本仓库,改由独立的 `npx add skills` 机制安装;本仓库的 `cli/package.json.files` 不再包含 `skill``scripts/postinstall.js`
## 发布
### beta / rc(不进 `latest` dist-tag)
`tools/release.mjs publish` 当前不接受 `--tag` 参数(见末尾 TODO)。pre-release 版本 npm 默认行为不会污染 `latest`,但稳妥起见,**直接用 pnpm 命令显式带 tag**:
```sh
# 先跑一次 check 确保通过
node tools/release.mjs check
# 然后显式发布到 beta dist-tag
pnpm --filter bailian-cli-core publish --tag beta --no-git-checks
pnpm --filter bailian-cli publish --tag beta --no-git-checks
```
### 正式版(默认进 latest)
```sh
node tools/release.mjs publish
```
## 完成后
- [ ] 推 git tag(如 `v1.0.0-beta.0`)
- [ ] 验证 npm 上能装:`npm view bailian-cli@beta version`
- [ ] 试装一次:`npm i -g bailian-cli@beta && bl --version`
## TODO(给 release.mjs 维护者)
- [ ] `releasePublish` 接受 `--tag <name>` 参数,beta/rc 不再绕开脚本
- [ ] `check` 增加 SKILL.md 与 `catalog.ts` / `reference/index.md` 一致性断言(如命令数、关键子命令名)
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| ------------------------------------------------ | ---------------------------------------------------------------------- |
| 改了源码忘 `vp pack`,直接 publish | npm 上是旧代码 — `tools/release.mjs publish` 会自动重建,**不要绕过它** |
| cli 升版号但 core 没升 | release.mjs 会拦下 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
+223
View File
@@ -0,0 +1,223 @@
# 批量压测脚本(多能力统一入口)
**使用者文档**方案说明、命令示例、fixtures、默认值表[../stress-testing.md](../stress-testing.md)
## 触发条件
- 新增、修改或排查 `packages/cli/tests/stress/**/*.mjs` 批量压测代码
- 用户要求并发压测 `bl` 各能力(文本 / 语音 / 图像 / 视频等)、生成 Markdown/HTML 报告
- 修改对应命令的 JSON 输出字段后,需同步 `packages/cli/tests/stress/lib/parsers.mjs` 与各 `targets/*.mjs` 的成功判定
- 在 monorepo 根 `package.json` 调整 `test:stress` 入口
**不要**为压测去改 CLI 产品行为(除非用户明确要求修 CLI bug压测侧通过子进程参数与解析逻辑适配现有命令。
## 与 E2E / `vp test` 的关系
| 维度 | E2EVitest | 批量压测(`.mjs` |
| ---- | ---------------------------------------- | ------------------------------------------------------------------ |
| 路径 | `packages/cli/tests/e2e/*.e2e.test.ts` | `packages/cli/tests/stress/run.mjs` + `targets/*.mjs``lib/*.mjs` |
| 运行 | `vp test`(真实 API 需 `BAILIAN_E2E=1` | **仅手动** `pnpm run test:stress -- <target> -- ...` |
| 目的 | 回归help、缺参、dry-run、单条集成 | 并发、限流、耗时统计、批量报告、前置资源 fixtures |
| CI | 可纳入 `vp test`skip 块默认跳过) | **禁止**默认 CI / `vp test` 自动执行 |
`vp test` 只收集 `*.test.ts` / `*.spec.ts` 等,**不会**执行 `tests/stress/*.mjs`。勿将压测逻辑写成 Vitest 用例并放进默认测试流。
E2E 规范见 [cli-e2e-tests.md](cli-e2e-tests.md)。
## 文件与入口
```
packages/cli/tests/stress/
├── run.mjs # 路由入口(解析 target、全局 flags、`--`
├── lib/
│ ├── argv-parse.mjs # 共用 --count/-n、-c、-m、--voice、--report-dir 等
│ ├── stress-config.mjs # count/concurrency 配置加载与解析
│ ├── cli-runner.mjs # spawn main.ts、限流重试、线程池
│ ├── fixtures.mjs # prerequisites.json + 前置音频/图/视频生成
│ ├── parsers.mjs # 各命令 stdout / 文件解析
│ ├── paths.mjs
│ ├── rate-limit.mjs
│ ├── finish-run.mjs # 写报告;套件模式返回摘要而不 exit
│ ├── run-suite.mjs # 全量套件编排
│ ├── suite-catalog.mjs # 用例顺序与中文名
│ ├── suite-report.mjs # SUITE_REPORT.md / .html
│ ├── trace-ids.mjs # requestId / taskId 提取
│ └── report.mjs # REPORT.md / REPORT.html / results.json
└── targets/
├── text-chat.mjs
├── speech-synthesize.mjs
├── speech-recognize.mjs
├── image-generate.mjs
├── image-edit.mjs
├── video-t2v.mjs
├── video-i2v.mjs
├── video-ref.mjs
└── video-edit.mjs
```
monorepo 根 `package.json`:
```json
"test:stress": "node packages/cli/tests/stress/run.mjs"
```
`pnpm` 会在子进程 argv 中插入 `--`;入口会**跳过**孤立的 `--`,因此 `pnpm run test:stress -- list``pnpm run test:stress list` 均可。
## 如何运行
在 **monorepo 根目录**执行(需已配置 `DASHSCOPE_API_KEY``~/.bailian/config.json`:
```sh
# 顺序执行全部 9 个用例,并生成套件总报告 SUITE_REPORT.md耗时长、会打真实 API
pnpm run test:stress
pnpm run test:stress -- all -- --count 5 -c 2
# 列出全部 target
pnpm run test:stress -- list
# 文本对话
pnpm run test:stress -- text -- --count 20 -c 5
# 语音合成(音色可用 --voice 或环境变量 STRESS_TTS_VOICE
pnpm run test:stress -- speech-tts -- --count 10
# 语音识别(先在同批次目录下生成 fixtures/setup-audio.mp3 等)
pnpm run test:stress -- speech-asr -- --count 5
# 生图 / 修图 / 视频类
pnpm run test:stress -- image-generate -- --count 50 -c 2
pnpm run test:stress -- image-edit --reuse-fixtures -- --count 10
pnpm run test:stress -- video-t2v -- --count 3 -c 1
pnpm run test:stress -- video-i2v --setup-only # 仅生成前置图 + manifest
pnpm run test:stress -- video-edit --reuse-fixtures -- --count 3
```
**环境变量**:可写在 `pnpm` 前,例如 `COUNT=5 pnpm run test:stress -- video-t2v`
**命令行参数**`--` 之后传给具体 target与旧 `stress:* -- --count` 语义一致)。
配置优先级:
- **任务数 / 并发**:命令行 `--count` / `-n``--concurrency` / `-c` > `packages/cli/tests/stress/stress.defaults.json`(或 `--stress-config` / `STRESS_CONFIG`> `lib/stress-config.mjs``CODE_DEFAULTS`
- **其它参数**(如 `MODEL`):命令行 > 环境变量 > target 内默认值
### 全局选项(由 `run.mjs` 解析,可从 argv 任意位置剥离)
| 选项 | 含义 |
| ----------------------- | ------------------------------------------------------------------------------- |
| `--reuse-fixtures` | 若当前批次的 `fixtures/prerequisites.json` 已存在且校验通过,则不再跑前置 CLI |
| `--setup-only` | 仅生成前置资源并写入 manifest不进入压测循环适用于带 fixtures 的 target |
| `--fixtures-dir <path>` | 使用**已有**目录下的 `prerequisites.json`(不拷贝),满足本 target 所需字段即可 |
前置 manifest 写入路径:`{REPORT_DIR}/fixtures/prerequisites.json`(默认 REPORT_DIR 含时间戳)。
## 脚本架构(不可随意破坏的约束)
### 子进程调用方式
- **实际执行**`node packages/cli/src/main.ts <args>``cwd``packages/cli`
- **禁止**用 `pnpm run dev` 跑子任务:`pnpm` 会向 stdout 打生命周期日志,污染 JSON 解析
- **报告中的「完整命令」**:用 `pnpm run dev ...` 展示(`buildDisplayCommand`
### 必须带的 CLI 参数(通用)
- `--non-interactive`
-`speech recognize` 外,压测子进程宜带 `--output json`(语音识别以 `--out` 文件为准 stdout 可能为纯文本)
- 异步类命令带 `--timeout`、对应 `--poll-interval`
**禁止**对子进程加 `--quiet`(与 `--output json` 并存时可能丢 `urls` / `video_url`)。
**禁止**对视频相关子进程加 `--no-wait`;须阻塞到任务完成(及下载路径正确时落盘)。
### 成功 / 失败判定(概要)
| Target / 能力 | 成功条件 |
| ------------- | ------------------------------------------------------------------------------------------ |
| text | JSON 含有效 `choices[0].message.content` 或流式汇总后的 `content` |
| speech-tts | JSON 含 `audio_url` / `audio_urls``saved` |
| speech-asr | 进程 exit 0`--out` JSON 可被解析出 `transcripts` 文本(或 stdout 有非空正文作为兜底) |
| image | JSON 含 `urls` 和/或 `saved`(不可仅凭 `task_id` 判失败) |
| video | JSON 含 `video_url` 和/或 `saved`(同上) |
详见 `lib/parsers.mjs`
### 墙钟耗时
报告中的 **墙钟总耗时** = `finishedAt - startedAt`(整批真实经过时间),不是各任务 `durationMs` 之和。
## 默认参数(摘要)
各 target 默认值见对应 `targets/*.mjs` 文件头与常量;常见约定:
- **count / concurrency**:见仓库内 `packages/cli/tests/stress/stress.defaults.json`,可按 target 修改
- **video-t2v / i2v / ref / edit**:默认视频类较低 `COUNT`、并发多为 1`TIMEOUT_MS` 可达 1 小时;前置 `video` fixtures 使用文生视频生成约 5s 样片
- **speech-asr**:默认 `POLL_INTERVAL=2`;输入音频由 `speech synthesize` 写入 fixtures
## 报告产物
### 单用例
每次运行在 `REPORT_DIR`(默认 `test/output/<target>-batch-<时间戳>/`)生成:
| 文件 | 内容 |
| ----------------------------- | ---------------------------------------------------------------------------------------- |
| `REPORT.md` | 汇总与明细表(含 **Request ID**、**Task ID** 列,便于排查) |
| `REPORT.html` | 同上 |
| `results.json` | 精简后的原始结果(含 `requestId` / `taskId` 字段) |
| `fixtures/prerequisites.json` | 需前置资源时:记录 `audio` / `image` / `video` 的 http URL、`saved` 本地路径与可复制命令 |
`requestId` / `taskId` 提取逻辑见 `lib/trace-ids.mjs`**仅压测脚本**
- 成功时 CLI stdout 通常不含 `request_id`;压测会默认加 `--verbose`,从 stderr 的 `request_id:` / JSON 块解析
-`task_id` 仍缺 `request_id` 时,压测会额外调用 DashScope `GET /tasks/{id}` 补齐(`lib/fetch-request-id.mjs`
- 关闭方式:`STRESS_VERBOSE_REQUEST_ID=0`(不注入 `--verbose`)、`STRESS_FETCH_REQUEST_ID=0`(不查任务 API
### 全量套件
`pnpm run test:stress`(无 target 或 `all`)在 `test/output/stress-suite-<时间戳>/` 下为每个用例建子目录,并生成:
| 文件 | 内容 |
| ----------------------- | ------------------------------------------------------------------- |
| `SUITE_REPORT.md` | 各用例中文名、墙钟耗时、任务数、并发、成功/失败、成功率、子报告路径 |
| `SUITE_REPORT.html` | 同上 |
| `suite-results.json` | 结构化汇总 |
| `<canonical>/REPORT.md` | 该用例明细报告 |
进程退出码:存在失败任务 → `1`,全部成功 → `0`
根目录 `.gitignore` 已忽略 `test/`,压测产物勿提交。
## Agent 修改清单
### 只改压测脚本时
- [ ] `lib/paths.mjs` 解析的 `CLI_PACKAGE` / `MONOREPO_ROOT` 仍正确
- [ ] 子进程仍为 `node` + `src/main.ts`,未改回裸 `pnpm run dev` 执行任务
- [ ] 未对子进程加 `--quiet`,视频未加 `--no-wait`
- [ ] `parsers.mjs` 与文档中的成功判定一致
- [ ]`package.json` 仅保留 `test:stress` 入口指向 `run.mjs`
- [ ] `node --check` 对相关 `.mjs` 通过,`pnpm run test:stress -- list` 可运行
### 改了某命令 JSON 输出时
- [ ] 同步 `lib/parsers.mjs` 及受影响 `targets/*.mjs`
- [ ] 有 API Key 时跑小规模:`pnpm run test:stress -- <target> -- --count 2 -c 1`
### 禁止(除非用户明确要求)
- [ ] 为压测专门改 CLI `--quiet` + `--output json` 行为
- [ ] 压测写进默认 `vp test` 路径且未 skip
- [ ] CI / `ready` 自动调用 `test:stress`
## 常见漏点
- 环境变量写在 `pnpm run test:stress` **之后** → 未注入进程
- **未知 target `"--"`**:已在新入口中跳过孤立 `--`;若仍报错请检查是否多空格或 shell 引用
- 高并发无节流 → DashScope Rate limitexit 4应依赖脚本内限流或降低 `-c`
- `image-generate` 大批量开启 `REPORT_THUMBNAILS=1` → HTML 变慢
- 改压测却未同步本文档与 `AGENTS.md`/`CLAUDE.md` 索引
## 与 E2E 的分工
- **改命令选项、help、缺参、单条真实调用** → `tests/e2e/*.e2e.test.ts`
- **并发、配额、fixtures、批量报告** → `tests/stress/` + **手动** `pnpm run test:stress -- <target>`
+74
View File
@@ -0,0 +1,74 @@
# URL / 渠道变更
## 触发条件
- 控制台域名变更(如 `bailian.console.aliyun.com` → 新域名)
- API endpoint 迁移
- 文档站迁移
- 调整 / 删除 / 新增渠道追踪参数(`source_channel` 等)
## URL 的分层架构
```
core/config/schema.ts ← API endpoint / 文档站(region-aware)
REGIONS{cn, us, intl} dashscope.aliyuncs.com 等
DOCS_HOSTS{cn, us, intl} help.aliyun.com/zh/model-studio
BAILIAN_HOST bailian.cn-beijing.aliyuncs.com (POP API)
cli/src/urls.ts ← 用户面控制台 URL(cn-only)
BAILIAN_CONSOLE_ROOT bailian.console.aliyun.com
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
```
## 必查清单
### A. TS 源码(必须 import,不准硬编码)
- [ ] `packages/core/src/config/schema.ts` 是所有 API/docs 基址的源头
- [ ] `packages/cli/src/urls.ts` 是所有用户面控制台 URL 的源头
- [ ] 改完后 grep 验证:
```sh
# 控制台 URL — 应只在 urls.ts 出现
grep -rnE "https://bailian\.console\.aliyun\.com" packages/ --include="*.ts" \
| grep -v "node_modules" | grep -v "/dist/"
# 期望:只匹配 packages/cli/src/urls.ts
# API endpoint — 应只在 schema.ts 和 upload.ts 出现
grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
| grep -v "node_modules" | grep -v "/dist/"
# 期望:只匹配 schema.ts(REGIONS)、upload.ts(派生)、tests
```
### B. 非 TS 文件(只能人工同步,无法 import)
- [ ] `tools/generated/reference/``<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对;本仓库 gitignore)
- [ ] `README.md` / `README_CN.md` 中所有 URL
### C. 渠道追踪参数
- [ ] **当前现状**:全仓不带 `source_channel=aliway` 等追踪参数
- [ ] 如未来要恢复以收集分析数据,**统一评估再加回**(不要单点恢复造成不一致)
- [ ] 全仓 grep `source_channel=`,确认无残留
## 完成后自查
```sh
# 验证错误 hint 不再泄漏旧 URL
HOME=/tmp/empty node packages/cli/src/main.ts text chat --message x --non-interactive
# 看输出的 Get API Key URL 是否走新值
# 验证 banner / help
node packages/cli/src/main.ts # banner
node packages/cli/src/main.ts help # help 命令
```
## 常见漏点
- ✗ 改了 `urls.ts` 但忘记同步 README(用户最先看到)
- ✗ 在 cli 命令文件里 inline `https://bailian.console.aliyun.com/...` 而不是 `${API_KEY_PAGE}`
- ✗ 在 core 的 hint 里写 URL(违反 [error-hint-change.md](error-hint-change.md) 不变量 1)
+34
View File
@@ -0,0 +1,34 @@
{
"name": "bailian-cli-monorepo",
"version": "0.0.0",
"private": true,
"homepage": "https://github.com/modelstudioai/cli",
"bugs": {
"url": "https://github.com/modelstudioai/cli/issues"
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/modelstudioai/cli.git"
},
"type": "module",
"scripts": {
"ready": "vp check && vp run -r test && vp run -r build",
"prepare": "vp config",
"check": "vp check",
"dev": "pnpm -F bailian-cli-core dev",
"bl": "pnpm -F bailian-cli dev",
"test": "vp test",
"release:check": "node tools/release.mjs check",
"release:publish": "node tools/release.mjs publish",
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
"test:stress": "node packages/cli/tests/stress/run.mjs"
},
"devDependencies": {
"vite-plus": "catalog:"
},
"engines": {
"node": ">=22.12.0"
},
"packageManager": "pnpm@10.33.2"
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.log
.DS_Store
outputs/
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Aliyun Model Studio (DashScope) AI Platform
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+145
View File
@@ -0,0 +1,145 @@
<div align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01RSQFUD1jN5IBzHORt_!!6000000004535-2-tps-2440-521.png" alt="Aliyun Model Studio CLI" width="420" />
# >\_ Aliyun Model Studio CLI
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli) · [中文文档](https://unpkg.com/bailian-cli/README_CN.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
---
_Chat with Qwen, generate images & videos, understand images, call agents,_
_manage memory, search the web — all from your terminal._
_Built for AI Agents. Every command works as a structured tool call._
</div>
## Features
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **Video generation & editing** — HappyHorse-1.0 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01Df2LiL1IcCkXJROYz_!!6000000000913-2-tps-759-426.png" alt="bl --help" width="720" />
</p>
## Installation
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
```
> Requires Node.js >= 22.12.
## Quick Start
```bash
# Authenticate
bl auth login --api-key sk-xxxxx
# Chat with Qwen
bl text chat --message "What is DashScope?"
# Multimodal chat (text + image + audio + video)
bl omni --message "Describe this image" --image ./photo.jpg
# Generate an image
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
# Generate a video from local image
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
# Browser login (required for console capability commands)
bl auth login --console
# Browse apps / free-tier quota
bl app list
bl usage free --model qwen3-max
```
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli)
## Authentication
### DashScope API Key
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key).
```bash
# Option 1: Environment variable
export DASHSCOPE_API_KEY=sk-xxxxx
# Option 2: Login command (persisted to ~/.bailian/config.json)
bl auth login --api-key sk-xxxxx
# Option 3: Per-command flag
bl text chat --api-key sk-xxxxx --message "Hello"
```
### Console Login (OAuth)
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
```
### Alibaba Cloud AK/SK (Knowledge Base only)
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
```bash
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## Configuration
```bash
# View current config
bl config show
# Set defaults
bl config set --key region --value us
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Self-update to latest version
bl update
```
Config file location: `~/.bailian/config.json`
## Links
| Resource | URL |
| :--------------------------- | :-------------------------------------------------------------- |
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli |
| DashScope API Docs | https://help.aliyun.com/zh/model-studio/ |
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/ |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
+145
View File
@@ -0,0 +1,145 @@
<div align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01RSQFUD1jN5IBzHORt_!!6000000004535-2-tps-2440-521.png" alt="Aliyun Model Studio CLI" width="420" />
# >\_ Aliyun Model Studio CLI
**阿里云百炼 (DashScope) AI 平台命令行工具**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli) · [English](https://unpkg.com/bailian-cli/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
---
_千问对话、图像生成与编辑、视频生成与编辑、图像理解、语音合成与识别、_
_应用调用、记忆管理、知识检索、联网搜索 — 一行命令,触达所有 AI 能力。_
_专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
</div>
## 功能特性
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01Df2LiL1IcCkXJROYz_!!6000000000913-2-tps-759-426.png" alt="bl --help" width="720" />
</p>
## 安装
```bash
npm install -g bailian-cli
npx skills add modelstudioai/skills --all -g
```
> 需要预先安装 Node.js >= 22.12。
## 快速开始
```bash
# 认证
bl auth login --api-key sk-xxxxx
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
# 多模态对话(文本 + 图片 + 音频 + 视频)
bl omni --message "描述这张图片" --image ./photo.jpg
# 生成图片
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
# 图生视频(本地文件自动上传)
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
# 浏览器登录(控制台能力相关命令需要)
bl auth login --console
# 浏览应用 / 免费额度
bl app list
bl usage free --model qwen3-max
```
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli)
## 认证方式
### DashScope API Key
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) 获取。
```bash
# 方式一:环境变量
export DASHSCOPE_API_KEY=sk-xxxxx
# 方式二:登录命令(持久化到 ~/.bailian/config.json
bl auth login --api-key sk-xxxxx
# 方式三:命令行参数
bl text chat --api-key sk-xxxxx --message "你好"
```
### 控制台登录OAuth
控制台能力命令(`app list``usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
```
### 阿里云 AK/SK仅知识库检索
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
```bash
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
export BAILIAN_WORKSPACE_ID=ws-...
```
## 配置
```bash
# 查看当前配置
bl config show
# 设置默认值
bl config set --key region --value us
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# 自更新到最新版本
bl update
```
配置文件位置:`~/.bailian/config.json`
## 相关链接
| 资源 | 地址 |
| :---------------------- | :-------------------------------------------------------------- |
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli |
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/ |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
+71
View File
@@ -0,0 +1,71 @@
{
"name": "bailian-cli",
"version": "1.1.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
"ai",
"cli",
"model-studio"
],
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
"url": "https://github.com/modelstudioai/cli/issues"
},
"license": "Apache-2.0",
"author": "Aliyun Model Studio",
"repository": {
"type": "git",
"url": "git+https://github.com/modelstudioai/cli.git",
"directory": "packages/cli"
},
"bin": {
"bailian": "dist/bailian.mjs",
"bl": "dist/bailian.mjs"
},
"files": [
"dist",
"README_CN.md"
],
"type": "module",
"exports": {
".": "./dist/bailian.mjs",
"./package.json": "./package.json"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts",
"build": "pnpm run generate:reference && vp pack",
"dev": "node src/main.ts",
"test": "vp test",
"check": "vp check"
},
"dependencies": {
"bailian-cli-core": "workspace:*"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
"@types/node": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"ajv": "catalog:",
"typescript": "^6.0.2",
"vite-plus": "catalog:",
"yaml": "catalog:"
},
"engines": {
"node": ">=22.12.0"
},
"inlinedDependencies": {
"@clack/core": "0.3.5",
"@clack/prompts": "0.7.0",
"ajv": "8.20.0",
"fast-deep-equal": "3.1.3",
"fast-uri": "3.1.2",
"json-schema-traverse": "1.0.0",
"picocolors": "1.1.1",
"sisteransi": "1.0.5",
"yaml": "2.8.3"
}
}
+166
View File
@@ -0,0 +1,166 @@
import type { GlobalFlags } from "bailian-cli-core";
import type { OptionDef } from "bailian-cli-core";
import { BailianError, ExitCode } from "bailian-cli-core";
function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Extract camelCase flag name from an OptionDef.flag string, e.g. '--max-tokens <n>' → 'maxTokens' */
function flagKey(def: OptionDef): string | null {
const m = def.flag.match(/^--([a-z][a-z0-9-]*)/i);
return m ? kebabToCamel(m[1]!) : null;
}
/** Boolean when no value placeholder and type is not string/number/array */
function isBooleanDef(def: OptionDef): boolean {
if (def.type === "boolean") return true;
if (def.type === "string" || def.type === "number" || def.type === "array") return false;
return !def.flag.includes("<") && !def.flag.includes("[");
}
interface FlagSchema {
booleans: Set<string>;
numbers: Set<string>;
arrays: Set<string>;
}
function buildSchema(options: OptionDef[]): FlagSchema {
const booleans = new Set<string>();
const numbers = new Set<string>();
const arrays = new Set<string>();
for (const opt of options) {
const key = flagKey(opt);
if (!key) continue;
if (isBooleanDef(opt)) booleans.add(key);
else if (opt.type === "number") numbers.add(key);
else if (opt.type === "array") arrays.add(key);
}
return { booleans, numbers, arrays };
}
/**
* Quick scan: collect positional (non-dash) args to determine the command path.
* Skips global flags and their values so that e.g. `--output json text chat`
* correctly produces ['text', 'chat'] instead of ['json', 'text', 'chat'].
*/
export function scanCommandPath(argv: string[], globalOptions: OptionDef[] = []): string[] {
const globalSchema = buildSchema(globalOptions);
const path: string[] = [];
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--") break;
if (arg.startsWith("--")) {
const eqIdx = arg.indexOf("=");
const key = eqIdx !== -1 ? arg.slice(2, eqIdx) : arg.slice(2);
const camelKey = kebabToCamel(key);
if (!globalSchema.booleans.has(camelKey) && eqIdx === -1) {
const next = argv[i + 1];
// Command-local booleans (e.g. `--console`) are not in GLOBAL_OPTIONS; if the next
// token is another flag, do not consume it as this flag's value.
if (next === undefined || next.startsWith("-")) {
i += 1;
} else {
i += 2;
}
} else {
i += 1;
}
continue;
}
if (arg.startsWith("-")) {
i++;
continue;
}
path.push(arg);
i++;
}
return path;
}
/**
* Full flag parse. Types are derived entirely from the provided OptionDef schema:
* - boolean: no <value> placeholder in flag string (or type: 'boolean')
* - number: type: 'number'
* - array: type: 'array' (repeatable via multiple --flag occurrences)
* - default: string
*/
export function parseFlags(argv: string[], options: OptionDef[]): GlobalFlags {
const schema = buildSchema(options);
const flags: GlobalFlags = {
quiet: false,
verbose: false,
noColor: false,
yes: false,
dryRun: false,
help: false,
nonInteractive: false,
async: false,
};
let i = 0;
while (i < argv.length) {
const arg = argv[i]!;
if (arg === "--help" || arg === "-h") {
flags.help = true;
i++;
continue;
}
if (arg === "--") {
break;
}
if (arg.startsWith("--")) {
const eqIdx = arg.indexOf("=");
let key: string;
let value: string | undefined;
if (eqIdx !== -1) {
key = arg.slice(2, eqIdx);
value = arg.slice(eqIdx + 1);
} else {
key = arg.slice(2);
}
const camelKey = kebabToCamel(key);
if (schema.booleans.has(camelKey)) {
(flags as Record<string, unknown>)[camelKey] = true;
i++;
continue;
}
if (value === undefined) {
i++;
value = argv[i];
}
if (value === undefined)
throw new BailianError(`Flag --${key} requires a value.`, ExitCode.USAGE);
if (schema.arrays.has(camelKey)) {
const arr = (flags as Record<string, unknown>)[camelKey] as string[] | undefined;
if (arr) arr.push(value);
else (flags as Record<string, unknown>)[camelKey] = [value];
} else if (schema.numbers.has(camelKey)) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
throw new BailianError(`Flag --${key} requires a finite number.`, ExitCode.USAGE);
}
(flags as Record<string, unknown>)[camelKey] = numericValue;
} else {
(flags as Record<string, unknown>)[camelKey] = value;
}
}
i++;
}
return flags;
}
+187
View File
@@ -0,0 +1,187 @@
import {
defineCommand,
request,
requestJson,
appCompletionEndpoint,
parseSSE,
detectOutputFormat,
type Config,
type GlobalFlags,
type AppCompletionRequest,
type AppStreamChunk,
type AppCompletionResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "app call",
description: "Call a Bailian application (agent or workflow)",
usage: "bl app call --app-id <id> --prompt <text> [flags]",
options: [
{ flag: "--app-id <id>", description: "Application ID (required)", required: true },
{ flag: "--prompt <text>", description: "Input prompt text", required: true },
{
flag: "--image <url>",
description: "Image URL(s) to pass to the app (repeatable)",
type: "array",
},
{ flag: "--file-id <id>", description: "Pre-uploaded file ID(s) (repeatable)", type: "array" },
{ flag: "--session-id <id>", description: "Session ID for multi-turn conversation" },
{ flag: "--stream", description: "Stream response (default: on in TTY)" },
{ flag: "--pipeline-ids <ids>", description: "Knowledge base pipeline IDs (comma-separated)" },
{ flag: "--memory-id <id>", description: "Memory ID for long-term memory" },
{ flag: "--biz-params <json>", description: "Business parameters JSON (workflow variables)" },
{ flag: "--has-thoughts", description: "Show agent thinking process" },
],
examples: [
'bl app call --app-id abc123 --prompt "你好"',
'bl app call --app-id abc123 --prompt "描述这张图片" --image https://example.com/photo.jpg',
'bl app call --app-id abc123 --prompt "分析图片" --image img1.jpg --image img2.jpg',
'bl app call --app-id abc123 --prompt "继续" --session-id sess_xxx --stream',
'bl app call --app-id abc123 --prompt "搜索资料" --pipeline-ids pipe1,pipe2',
'bl app call --app-id abc123 --prompt "开始" --biz-params \'{"key":"value"}\'',
],
async run(config: Config, flags: GlobalFlags) {
const appId = flags.appId as string;
if (!appId) failIfMissing("app-id", "bl app call --app-id <id> --prompt <text>");
const prompt = flags.prompt as string;
if (!prompt) failIfMissing("prompt", "bl app call --app-id <id> --prompt <text>");
const shouldStream =
flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
const format = detectOutputFormat(config.output);
const body: AppCompletionRequest = {
input: { prompt },
parameters: {
incremental_output: shouldStream,
},
};
if (flags.sessionId) {
body.input.session_id = flags.sessionId as string;
}
// Pass image URLs via image_list
const imageUrls = flags.image as string[] | undefined;
if (imageUrls && imageUrls.length > 0) {
body.input.image_list = imageUrls;
}
// Pass pre-uploaded file IDs
const fileIds = flags.fileId as string[] | undefined;
if (fileIds && fileIds.length > 0) {
body.input.file_ids = fileIds;
}
if (flags.hasThoughts) {
body.parameters!.has_thoughts = true;
}
if (flags.pipelineIds) {
const ids = (flags.pipelineIds as string)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
body.parameters!.rag_options = { pipeline_ids: ids };
}
if (flags.memoryId) {
body.parameters!.memory_id = flags.memoryId as string;
}
if (flags.bizParams) {
try {
body.input.biz_params = JSON.parse(flags.bizParams as string);
} catch {
process.stderr.write("Error: --biz-params must be valid JSON\n");
process.exit(1);
}
}
if (config.dryRun) {
emitResult({ endpoint: appCompletionEndpoint(config.baseUrl, appId), request: body }, format);
return;
}
const url = appCompletionEndpoint(config.baseUrl, appId);
if (shouldStream) {
const headers: Record<string, string> = { "X-DashScope-SSE": "enable" };
const res = await request(config, {
url,
method: "POST",
body,
headers,
stream: true,
});
let fullText = "";
let sessionId = "";
const writesStreamingStdout = format === "text";
const dim = config.noColor ? "" : "\x1b[2m";
const reset = config.noColor ? "" : "\x1b[0m";
for await (const event of parseSSE(res)) {
if (event.data === "[DONE]") break;
try {
const chunk = JSON.parse(event.data) as AppStreamChunk;
const text = chunk.output?.text;
if (text) {
// incremental_output: text is delta
if (writesStreamingStdout) process.stdout.write(text);
fullText += text;
}
// Capture session_id for multi-turn
if (chunk.output?.session_id) {
sessionId = chunk.output.session_id;
}
// Show thoughts if available
if (chunk.output?.thoughts && flags.hasThoughts) {
for (const t of chunk.output.thoughts) {
if (t.thought) process.stderr.write(`${dim}[Thinking] ${t.thought}${reset}\n`);
if (t.action_name)
process.stderr.write(
`${dim}[Action] ${t.action_name}: ${t.action_input || ""}${reset}\n`,
);
if (t.observation)
process.stderr.write(`${dim}[Observation] ${t.observation}${reset}\n`);
}
}
} catch {
// skip unparseable
}
}
// Show session_id for multi-turn conversation
if (sessionId && !config.quiet) {
process.stderr.write(`${dim}Session ID: ${sessionId}${reset}\n`);
}
if (format === "json") {
emitResult({ text: fullText, session_id: sessionId }, format);
} else {
process.stdout.write("\n");
}
} else {
const response = await requestJson<AppCompletionResponse>(config, {
url,
method: "POST",
body,
});
const text = response.output?.text ?? "";
if (config.quiet || format === "text") {
emitBare(text);
} else {
emitResult(response, format);
}
}
},
});
+88
View File
@@ -0,0 +1,88 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
const APP_LIST_API = "zeldaEasy.broadscope-bailian.app-control.list";
export default defineCommand({
name: "app list",
description: "List Bailian applications",
usage: "bl app list [flags]",
options: [
{
flag: "--name <name>",
description: "Filter by app name (keyword search)",
},
{
flag: "--page <n>",
description: "Page number (default: 1)",
type: "number",
},
{
flag: "--page-size <n>",
description: "Results per page (default: 30)",
type: "number",
},
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
},
],
examples: [
"bl app list",
"bl app list --name 客服",
"bl app list --page 2 --page-size 10",
"bl app list --output json",
],
async run(config: Config, flags: GlobalFlags) {
const name = (flags.name as string) || "";
const pageNo = (flags.page as number) || 1;
const pageSize = (flags.pageSize as number) || 30;
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
const data = {
reqDTO: {
name,
notInTypes: [10],
type: 5,
statuses: [1, 4],
page_no: pageNo,
page_size: pageSize,
},
};
if (config.dryRun) {
emitResult(
{ api: APP_LIST_API, data, region, token: credential.token.slice(0, 8) + "..." },
format,
);
return;
}
const result = (await callConsoleGateway(config, credential.token, {
api: APP_LIST_API,
data,
region,
})) as any;
const list: unknown[] = result?.data?.DataV2?.data?.data?.list ?? [];
const total: number = result?.data?.DataV2?.data?.data?.total ?? 0;
const apps = list.map((item: any) => ({
code: item.code,
name: item.name,
user_prompt_params: item.config?.user_prompt_params ?? [],
}));
emitResult({ total, apps }, format);
},
});
+390
View File
@@ -0,0 +1,390 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import http from "node:http";
import {
BailianError,
ExitCode,
chatEndpoint,
defineCommand,
getConfigPath,
isInteractive,
maskToken,
readConfigFile,
requestJson,
writeConfigFile,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { printQuickStart } from "../../output/banner.ts";
import { emitBare } from "../../output/output.ts";
import { promptConfirm } from "../../output/prompt.ts";
import { printCurrentCommandHelp } from "../../utils/command-help.ts";
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_AUTH_CALLBACK_BODY = 65536;
const DEFAULT_CONSOLE_ORIGIN = "https://bailian.console.aliyun.com";
function resolveConsoleOrigin(): string {
return process.env.BAILIAN_CONSOLE_ORIGIN || DEFAULT_CONSOLE_ORIGIN;
}
function readBodyBounded(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_AUTH_CALLBACK_BODY) {
reject(new Error("payload too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
function requestContentType(req: http.IncomingMessage): string {
const h = req.headers["content-type"];
if (Array.isArray(h)) return h[0] ?? "";
return typeof h === "string" ? h : "";
}
function multipartBoundary(contentType: string): string | null {
const parts = contentType.split(";");
for (const p of parts) {
const s = p.trim();
if (!s.toLowerCase().startsWith("boundary=")) continue;
let b = s.slice("boundary=".length).trim();
if ((b.startsWith('"') && b.endsWith('"')) || (b.startsWith("'") && b.endsWith("'"))) {
b = b.slice(1, -1);
}
return b.length > 0 ? b : null;
}
return null;
}
/** multipart/form-data: find part with name=access_token and return its body. */
function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): string | null {
const delim = `--${boundaryValue}`;
const segments = raw.split(delim);
for (let i = 1; i < segments.length; i++) {
const part = segments[i]!;
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
const sep = part.match(/\r\n\r\n|\n\n/);
if (!sep || sep.index === undefined) continue;
let value = part.slice(sep.index + sep[0].length);
value = value
.replace(/(?:\r\n)+$/g, "")
.replace(/\n+$/g, "")
.trim();
if (value) return value;
}
return null;
}
function tokenFieldFromRecord(o: Record<string, unknown>): string | null {
for (const k of ["access_token", "accessToken"]) {
const v = o[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function parseAccessTokenFromJsonText(text: string): string | null {
let t = text.trim();
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
if (!t) return null;
let j: unknown;
try {
j = JSON.parse(t);
} catch {
return null;
}
if (!j || typeof j !== "object" || Array.isArray(j)) return null;
const o = j as Record<string, unknown>;
const direct = tokenFieldFromRecord(o);
if (direct) return direct;
const data = o.data;
if (data && typeof data === "object" && !Array.isArray(data)) {
const inner = tokenFieldFromRecord(data as Record<string, unknown>);
if (inner) return inner;
}
return null;
}
function parseAccessTokenFromRawBody(raw: string, contentType: string): string | null {
const ct = contentType.toLowerCase();
if (!raw.trim()) return null;
if (ct.includes("multipart/form-data")) {
const b = multipartBoundary(contentType);
if (b) {
const tok = parseAccessTokenFromMultipart(raw, b);
if (tok) return tok;
}
}
if (ct.includes("application/json") || ct.includes("text/json")) {
const t = parseAccessTokenFromJsonText(raw);
if (t) return t;
}
if (ct.includes("application/x-www-form-urlencoded")) {
try {
const params = new URLSearchParams(raw.trim());
const v = params.get("access_token") ?? params.get("accessToken");
if (v?.trim()) return v.trim();
} catch {
/* */
}
}
// Fallbacks when Content-Type is missing or nonstandard (many fetch() callers omit it).
const jsonTok = parseAccessTokenFromJsonText(raw);
if (jsonTok) return jsonTok;
try {
const params = new URLSearchParams(raw.trim());
const v = params.get("access_token") ?? params.get("accessToken");
if (v?.trim()) return v.trim();
} catch {
/* */
}
const b = multipartBoundary(contentType);
if (b) {
const tok = parseAccessTokenFromMultipart(raw, b);
if (tok) return tok;
}
return null;
}
async function extractAccessTokenFromRequest(req: http.IncomingMessage): Promise<string | null> {
const u = new URL(req.url ?? "/", "http://127.0.0.1");
const fromQuery = u.searchParams.get("access_token") ?? u.searchParams.get("accessToken");
if (fromQuery?.trim()) return fromQuery.trim();
const m = req.method ?? "GET";
if (m !== "POST" && m !== "PUT" && m !== "PATCH") return null;
const contentType = requestContentType(req);
try {
const raw = await readBodyBounded(req);
return parseAccessTokenFromRawBody(raw, contentType);
} catch {
return null;
}
}
/** Binds to an ephemeral port on loopback; the OS only assigns ports that are free at bind time. */
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
const onErr = (e: Error) => reject(e);
server.once("error", onErr);
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
server.off("error", onErr);
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Expected TCP socket address"));
return;
}
resolve(addr.port);
});
});
}
function openInBrowser(url: string): Promise<void> {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
async function validateKeyAndPersist(config: Config, key: string): Promise<void> {
process.stderr.write("Testing key... ");
const testConfig = { ...config, apiKey: key };
await requestJson<unknown>(testConfig, {
url: chatEndpoint(testConfig.baseUrl),
method: "POST",
body: {
model: "qwen3.7-max",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
},
});
process.stderr.write("Valid\n");
const existing = readConfigFile() as Record<string, unknown>;
existing.api_key = key;
await writeConfigFile(existing);
process.stderr.write(`Saved to ${getConfigPath()}\n`);
}
/** Listens on 127.0.0.1:<port> so the console can reach the address passed to the browser. */
async function runConsoleLogin(consoleOrigin: string): Promise<void> {
const state = randomBytes(16).toString("hex");
const server = http.createServer(async (req, res) => {
try {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
const u = new URL(req.url ?? "/", "http://127.0.0.1");
if (u.searchParams.get("state") !== state) {
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
res.end("bad state\n");
return;
}
const accessToken = await extractAccessTokenFromRequest(req);
if (accessToken) {
try {
const existing = readConfigFile() as Record<string, unknown>;
existing.access_token = accessToken;
await writeConfigFile(existing);
process.stderr.write(`access_token saved to ${getConfigPath()}\n`);
} catch {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Failed to save access_token\n");
return;
}
}
res.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
});
res.end("OK\n");
if (accessToken) {
server.close();
}
} catch {
res.statusCode = 500;
res.end();
}
});
let port: number;
try {
port = await listenServerOnFreeLocalPort(server);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
throw new BailianError(
`Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`,
ExitCode.USAGE,
);
}
const loginUrl = `${consoleOrigin}/console-login?notice=127.0.0.1:${port}?state=${encodeURIComponent(state)}`;
try {
await openInBrowser(loginUrl);
process.stderr.write(
"Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`Could not open the default browser (${msg}). Open this URL manually:\n\n`,
);
process.stdout.write(`${loginUrl}\n`);
process.stderr.write(
"\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n",
);
}
await new Promise<void>((resolve, reject) => {
let finished = false;
const done = () => {
if (finished) return;
finished = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
server.close();
}, CONSOLE_LOGIN_TIMEOUT_MS);
server.once("close", done);
server.once("error", (err) => {
clearTimeout(timer);
if (!finished) {
finished = true;
reject(err);
}
});
});
}
export default defineCommand({
name: "auth login",
description: "Authenticate with API key or console browser login (credentials can coexist)",
usage: "bl auth login --api-key <key> | bl auth login --console",
options: [
{ flag: "--api-key <key>", description: "DashScope API key to store" },
{
flag: "--console",
description: "Sign in via browser; opens the console login URL in your default browser",
type: "boolean",
},
],
examples: ["bl auth login --api-key sk-xxxxx", "bl auth login --console"],
async run(config: Config, flags: GlobalFlags) {
if (flags.console) {
if (config.dryRun) {
emitBare(
"Would bind a free port on 127.0.0.1 and open the console login URL in your browser.",
);
return;
}
await runConsoleLogin(resolveConsoleOrigin());
return;
}
const envKey = process.env.DASHSCOPE_API_KEY;
if (envKey && !flags.apiKey) {
const maskedEnvKey = maskToken(envKey);
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const proceed = await promptConfirm({
message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`,
initialValue: false,
});
if (!proceed) {
process.stdout.write("Login skipped. Using environment variables.\n");
process.exit(0);
}
} else {
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
}
}
const key = (flags.apiKey as string) || config.apiKey;
if (!key) {
printCurrentCommandHelp(process.stderr);
process.exit(0);
}
if (!config.dryRun) {
await validateKeyAndPersist(config, key);
printQuickStart();
} else {
emitBare("Would validate and save API key.");
}
},
});
+79
View File
@@ -0,0 +1,79 @@
import {
defineCommand,
clearApiKey,
readConfigFile,
writeConfigFile,
getConfigPath,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitBare } from "../../output/output.ts";
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({
name: "auth logout",
description: "Clear stored credentials",
usage: "bl auth logout [--console] [--yes] [--dry-run]",
options: [
{
flag: "--console",
description: "Only clear the console access_token, keep api_key intact",
type: "boolean",
},
{ flag: "--yes", description: "Skip confirmation prompt" },
],
examples: [
"bl auth logout",
"bl auth logout --console",
"bl auth logout --dry-run",
"bl auth logout --yes",
],
async run(config: Config, flags: GlobalFlags) {
const file = readConfigFile();
if (flags.console) {
const hasToken = !!file.access_token;
if (config.dryRun) {
if (hasToken) 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();
process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`);
if (file.api_key) {
process.stderr.write(
"api_key is still configured and will be used for authentication.\n",
);
}
} else {
process.stderr.write("No console access_token to clear.\n");
}
return;
}
const hasKey = !!(file.api_key || file.access_token);
if (config.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();
process.stderr.write("Cleared api_key / access_token from ~/.bailian/config.json\n");
} else {
process.stderr.write("No credentials to clear.\n");
}
},
});
+172
View File
@@ -0,0 +1,172 @@
import {
defineCommand,
resolveCredential,
resolveConsoleGatewayCredential,
detectOutputFormat,
maskToken,
type Config,
type GlobalFlags,
type ResolvedCredential,
} from "bailian-cli-core";
import { emitResult, emitBare } from "../../output/output.ts";
import { API_KEY_PAGE } from "../../urls.ts";
interface StoredCredential {
configured: boolean;
source?: string;
masked?: string;
}
interface AuthStatusPayload {
api_key: StoredCredential;
access_token: StoredCredential;
dashscope_commands?: { method: string; source: string; masked: string };
console_gateway_commands?: { method: string; source: string; masked: string };
}
function storedApiKey(config: Config): StoredCredential {
if (config.apiKey) {
return { configured: true, source: "flag", masked: maskToken(config.apiKey) };
}
if (config.fileApiKey) {
return { configured: true, source: "config.json", masked: maskToken(config.fileApiKey) };
}
const env = process.env.DASHSCOPE_API_KEY?.trim();
if (env) {
return { configured: true, source: "DASHSCOPE_API_KEY", masked: maskToken(env) };
}
return { configured: false };
}
function storedAccessToken(config: Config): StoredCredential {
if (config.accessTokenEnv) {
return {
configured: true,
source: "DASHSCOPE_ACCESS_TOKEN",
masked: maskToken(config.accessTokenEnv),
};
}
if (config.fileAccessToken) {
return {
configured: true,
source: "config.json",
masked: maskToken(config.fileAccessToken),
};
}
return { configured: false };
}
async function tryResolveDashscope(config: Config): Promise<ResolvedCredential | undefined> {
try {
return await resolveCredential(config);
} catch {
return undefined;
}
}
async function tryResolveConsole(config: Config): Promise<ResolvedCredential | undefined> {
try {
return await resolveConsoleGatewayCredential(config);
} catch {
return undefined;
}
}
async function buildStatus(config: Config): Promise<AuthStatusPayload> {
const status: AuthStatusPayload = {
api_key: storedApiKey(config),
access_token: storedAccessToken(config),
};
const dashscope = await tryResolveDashscope(config);
if (dashscope) {
status.dashscope_commands = {
method: dashscope.method,
source: dashscope.source,
masked: maskToken(dashscope.token),
};
}
const consoleGw = await tryResolveConsole(config);
if (consoleGw) {
status.console_gateway_commands = {
method: consoleGw.method,
source: consoleGw.source,
masked: maskToken(consoleGw.token),
};
}
return status;
}
function hasAnyAuth(status: AuthStatusPayload): boolean {
return (
status.api_key.configured ||
status.access_token.configured ||
!!status.dashscope_commands ||
!!status.console_gateway_commands
);
}
function emitTextStatus(status: AuthStatusPayload): void {
emitBare("Authentication Status:");
emitBare(" Stored credentials (can coexist):");
if (status.api_key.configured) {
emitBare(` API key: ${status.api_key.source} ${status.api_key.masked}`);
} else {
emitBare(" API key: not configured");
}
if (status.access_token.configured) {
emitBare(` Console token: ${status.access_token.source} ${status.access_token.masked}`);
} else {
emitBare(" Console token: not configured");
}
emitBare(" Effective credential per command family:");
if (status.dashscope_commands) {
emitBare(
` DashScope API: ${status.dashscope_commands.method} (${status.dashscope_commands.source}) ${status.dashscope_commands.masked}`,
);
} else {
emitBare(" DashScope API: unavailable");
}
if (status.console_gateway_commands) {
emitBare(
` Console gateway: ${status.console_gateway_commands.method} (${status.console_gateway_commands.source}) ${status.console_gateway_commands.masked}`,
);
} else {
emitBare(" Console gateway: unavailable (run bl auth login --console)");
}
}
export default defineCommand({
name: "auth status",
description: "Show current authentication state",
usage: "bl auth status",
examples: ["bl auth status", "bl auth status --output json"],
async run(config: Config, _flags: GlobalFlags) {
const format = detectOutputFormat(config.output);
const status = await buildStatus(config);
if (!hasAnyAuth(status)) {
const result = {
authenticated: false,
message: "Not authenticated.",
hint: [
"DashScope API: bl auth login --api-key <key> or DASHSCOPE_API_KEY",
"Console gateway: bl auth login --console or DASHSCOPE_ACCESS_TOKEN",
`Get API Key: ${API_KEY_PAGE}`,
].join("\n"),
...status,
};
emitResult(result, format);
return;
}
if (format !== "text") {
emitResult({ authenticated: true, ...status }, format);
return;
}
emitTextStatus(status);
},
});
+76
View File
@@ -0,0 +1,76 @@
import type { Command } from "bailian-cli-core";
import authLogin from "./auth/login.ts";
import authStatus from "./auth/status.ts";
import authLogout from "./auth/logout.ts";
import textChat from "./text/chat.ts";
import textOmni from "./omni/chat.ts";
import imageGenerate from "./image/generate.ts";
import imageEdit from "./image/edit.ts";
import videoGenerate from "./video/generate.ts";
import videoEdit from "./video/edit.ts";
import videoRef from "./video/ref.ts";
import videoTaskGet from "./video/task-get.ts";
import videoDownload from "./video/download.ts";
import visionDescribe from "./vision/describe.ts";
import configShow from "./config/show.ts";
import configSet from "./config/set.ts";
import configExportSchema from "./config/export-schema.ts";
import update from "./update.ts";
import appCall from "./app/call.ts";
import appList from "./app/list.ts";
import memoryAdd from "./memory/add.ts";
import memorySearch from "./memory/search.ts";
import memoryList from "./memory/list.ts";
import memoryUpdate from "./memory/update.ts";
import memoryDelete from "./memory/delete.ts";
import memoryProfileCreate from "./memory/profile-create.ts";
import memoryProfileGet from "./memory/profile-get.ts";
import knowledgeRetrieve from "./knowledge/retrieve.ts";
import searchWeb from "./search/web.ts";
import speechSynthesize from "./speech/synthesize.ts";
import speechRecognize from "./speech/recognize.ts";
import fileUpload from "./file/upload.ts";
import consoleCall from "./console/call.ts";
import usageFree from "./usage/free.ts";
import pipelineRun from "./pipeline/run.ts";
import pipelineValidate from "./pipeline/validate.ts";
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
export const commands: Record<string, Command> = {
"auth login": authLogin,
"auth status": authStatus,
"auth logout": authLogout,
"text chat": textChat,
omni: textOmni,
"image generate": imageGenerate,
"image edit": imageEdit,
"video generate": videoGenerate,
"video edit": videoEdit,
"video ref": videoRef,
"video task get": videoTaskGet,
"video download": videoDownload,
"vision describe": visionDescribe,
"app call": appCall,
"app list": appList,
"memory add": memoryAdd,
"memory search": memorySearch,
"memory list": memoryList,
"memory update": memoryUpdate,
"memory delete": memoryDelete,
"memory profile create": memoryProfileCreate,
"memory profile get": memoryProfileGet,
"knowledge retrieve": knowledgeRetrieve,
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
"pipeline run": pipelineRun,
"pipeline validate": pipelineValidate,
"config show": configShow,
"config set": configSet,
"config export-schema": configExportSchema,
update: update,
};
@@ -0,0 +1,46 @@
import { defineCommand, generateToolSchema } from "bailian-cli-core";
import type { Config } from "bailian-cli-core";
import type { GlobalFlags } from "bailian-cli-core";
import { BailianError } from "bailian-cli-core";
import { ExitCode } from "bailian-cli-core";
/**
* Commands that are infrastructure/auth-related and not suitable as Agent tools.
*/
const SKIP_PREFIXES = ["auth ", "config ", "update"];
export default defineCommand({
name: "config export-schema",
description:
"Export all (or one) CLI command(s) as Anthropic/OpenAI-compatible JSON tool schemas",
usage: 'bl config export-schema [--command "<name>"]',
options: [
{
flag: "--command <name>",
description: 'Export schema for a specific command only (e.g. "image generate")',
},
],
examples: ["bl config export-schema", 'bl config export-schema --command "video generate"'],
async run(config: Config, flags: GlobalFlags) {
const { commands } = await import("../catalog.ts");
const targetCommand = flags.command as string | undefined;
if (targetCommand) {
const command = commands[targetCommand];
if (!command) {
throw new BailianError(`Command "${targetCommand}" not found.`, ExitCode.USAGE);
}
const schema = generateToolSchema(command);
process.stdout.write(JSON.stringify(schema, null, 2) + "\n");
return;
}
// Export all suitable commands
const allCommands = Object.values(commands);
const schemas = allCommands
.filter((c) => !SKIP_PREFIXES.some((p) => c.name.startsWith(p)))
.map((c) => generateToolSchema(c));
process.stdout.write(JSON.stringify(schemas, null, 2) + "\n");
},
});
+126
View File
@@ -0,0 +1,126 @@
import {
defineCommand,
detectOutputFormat,
readConfigFile,
writeConfigFile,
BailianError,
type Config,
type GlobalFlags,
ExitCode,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
const VALID_KEYS = [
"region",
"base_url",
"output",
"output_dir",
"timeout",
"api_key",
"access_token",
"default_text_model",
"default_video_model",
"default_image_model",
"default_speech_model",
"default_omni_model",
"access_key_id",
"access_key_secret",
"workspace_id",
];
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
"output-dir": "output_dir",
"api-key": "api_key",
"access-token": "access_token",
"default-text-model": "default_text_model",
"default-video-model": "default_video_model",
"default-image-model": "default_image_model",
"default-speech-model": "default_speech_model",
"default-omni-model": "default_omni_model",
"access-key-id": "access_key_id",
"access-key-secret": "access_key_secret",
"workspace-id": "workspace_id",
};
export default defineCommand({
name: "config set",
description: "Set a config value",
usage: "bl config set --key <key> --value <value>",
options: [
{
flag: "--key <key>",
description:
"Config key (region, base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)",
},
{ flag: "--value <value>", description: "Value to set" },
],
examples: [
"bl config set --key output --value json",
"bl config set --key timeout --value 600",
"bl config set --key base_url --value https://dashscope.aliyuncs.com",
],
async run(config: Config, flags: GlobalFlags) {
const key = flags.key as string | undefined;
const value = flags.value as string | undefined;
if (!key || value === undefined) {
throw new BailianError(
"--key and --value are required.",
ExitCode.USAGE,
"bl config set --key <key> --value <value>",
);
}
// Resolve hyphen aliases to underscore keys
const resolvedKey: string = KEY_ALIASES[key] || key;
if (!VALID_KEYS.includes(resolvedKey)) {
throw new BailianError(
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
ExitCode.USAGE,
);
}
// Validate specific values
if (resolvedKey === "region" && !["cn", "us", "intl"].includes(value)) {
throw new BailianError(
`Invalid region "${value}". Valid values: cn, us, intl`,
ExitCode.USAGE,
);
}
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
ExitCode.USAGE,
);
}
if (resolvedKey === "timeout") {
const num = Number(value);
if (isNaN(num) || num <= 0) {
throw new BailianError(
`Invalid timeout "${value}". Must be a positive number.`,
ExitCode.USAGE,
);
}
}
const format = detectOutputFormat(config.output);
if (config.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);
if (!config.quiet) {
emitResult({ [resolvedKey]: existing[resolvedKey] }, format);
}
},
});
+44
View File
@@ -0,0 +1,44 @@
import {
defineCommand,
readConfigFile as loadConfigFile,
getConfigPath,
detectOutputFormat,
maskToken,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
export default defineCommand({
name: "config show",
description: "Display current configuration",
usage: "bl config show",
examples: ["bl config show", "bl config show --output json"],
async run(config: Config, _flags: GlobalFlags) {
const file = loadConfigFile();
const format = detectOutputFormat(config.output);
const result: Record<string, unknown> = {
region: config.region,
base_url: config.baseUrl,
output: config.output,
timeout: config.timeout,
config_file: getConfigPath(),
};
// Mask API key if present
if (file.api_key) {
result.api_key = maskToken(file.api_key);
}
if (file.access_token) {
result.access_token = maskToken(file.access_token);
}
// Default models
if (file.default_text_model) result.default_text_model = file.default_text_model;
if (file.default_video_model) result.default_video_model = file.default_video_model;
if (file.default_image_model) result.default_image_model = file.default_image_model;
emitResult(result, format);
},
});
+78
View File
@@ -0,0 +1,78 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
CONSOLE_GATEWAY_NO_TOKEN_MESSAGE,
BailianError,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
export default defineCommand({
name: "console call",
description: "Call a Bailian console API via the CLI gateway",
usage: "bl console call --api <api> --data <json> [flags]",
options: [
{
flag: "--api <api>",
description: "API name (e.g. zeldaEasy.broadscope-bailian.memory-library.getLibraries)",
required: true,
},
{
flag: "--data <json>",
description: "Request data as JSON string",
required: true,
},
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
},
],
examples: [
`bl console call --api zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota --data '{"queryFreeTierQuotaRequest":{"models":["qwen3-max"]}}'`,
`bl console call --api some.api.name --data '{"key":"value"}' --region cn-beijing`,
],
async run(config: Config, flags: GlobalFlags) {
const api = flags.api as string;
if (!api) failIfMissing("api", "bl console call --api <api> --data <json>");
const dataRaw = flags.data as string;
if (!dataRaw) failIfMissing("data", "bl console call --api <api> --data <json>");
let data: Record<string, unknown>;
try {
data = JSON.parse(dataRaw) as Record<string, unknown>;
} catch {
process.stderr.write("Error: --data must be valid JSON\n");
process.exit(1);
}
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
let token: string | undefined;
try {
token = (await resolveConsoleGatewayCredential(config)).token;
} catch (err) {
if (!(err instanceof BailianError && err.message === CONSOLE_GATEWAY_NO_TOKEN_MESSAGE)) {
throw err;
}
}
if (config.dryRun) {
emitResult({ api, data, region, token: token ? token.slice(0, 8) + "..." : null }, format);
return;
}
const result = await callConsoleGateway(config, token, {
api,
data,
region,
});
emitResult(result, format);
},
});
+76
View File
@@ -0,0 +1,76 @@
import {
defineCommand,
resolveCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
uploadFile,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "file upload",
description: "Upload a local file to DashScope temporary storage (48h)",
apiDocs: "/developer-reference/get-temporary-file-url",
usage: "bl file upload --file <path> --model <model>",
options: [
{
flag: "--file <path>",
description: "Local file to upload (image, video, audio)",
required: true,
},
{
flag: "--model <model>",
description: "Target model name (file is bound to this model)",
required: true,
},
],
examples: [
"bl file upload --file photo.jpg --model qwen-vl-max",
"bl file upload --file video.mp4 --model wan2.1-t2v-plus",
"bl file upload --file audio.wav --model qwen3-asr-flash",
"bl file upload --file cat.png --model qwen-image-2.0",
],
async run(config: Config, flags: GlobalFlags) {
const filePath = flags.file as string | undefined;
if (!filePath) {
failIfMissing("file", "bl file upload --file <path> --model <model>");
}
const model = flags.model as string | undefined;
if (!model) {
failIfMissing("model", "bl file upload --file <path> --model <model>");
}
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ action: "upload", file: filePath, model }, format);
return;
}
// Resolve API key for upload
const credential = await resolveCredential(config);
const ossUrl = await uploadFile({
apiKey: credential.token,
model: model!,
filePath: filePath!,
});
if (config.quiet) {
emitBare(ossUrl);
} else {
emitResult(
{
url: ossUrl,
model,
expires_in: "48 hours",
note: "When using this URL in API calls, add header: X-DashScope-OssResourceResolve: enable",
},
format,
);
}
},
});
+196
View File
@@ -0,0 +1,196 @@
import {
defineCommand,
requestJson,
imageSyncEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
resolveCredential,
resolveFileUrl,
resolveOutputDir,
generateFilename,
isInteractive,
stripUndefined,
type DashScopeImageRequest,
type DashScopeImageSyncResponse,
ExitCode,
BailianError,
} from "bailian-cli-core";
import { downloadFile } from "../../utils/download.ts";
import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { resolveImageSize } from "../../utils/image-size.ts";
import { join } from "path";
export default defineCommand({
name: "image edit",
description: "Edit an existing image with text instructions (Qwen-Image)",
apiDocs: "/developer-reference/qwen-image-edit-api",
usage: "bl image edit --image <url> --prompt <text> [flags]",
options: [
{
flag: "--image <url>",
description: "Source image URL or local file path (repeatable for multi-image merge)",
required: true,
type: "array",
},
{ flag: "--prompt <text>", description: "Edit instruction text", required: true },
{ flag: "--model <model>", description: "Model ID (default: qwen-image-2.0)" },
{
flag: "--size <W*H>",
description: "Output image size: ratio (3:4, 16:9) or pixels (2048*2048)",
},
{ flag: "--n <count>", description: "Number of images (default: 1, max: 6)", type: "number" },
{ flag: "--seed <n>", description: "Random seed for reproducible results", type: "number" },
{
flag: "--negative-prompt <text>",
description: "Negative prompt to exclude unwanted content",
},
{ flag: "--prompt-extend", description: "Enable prompt smart rewrite (default: true)" },
{ flag: "--no-prompt-extend", description: "Disable prompt extend" },
{ flag: "--watermark", description: "Add watermark to output images" },
{ flag: "--out-dir <dir>", description: "Download images to directory" },
{ flag: "--out-prefix <prefix>", description: "Filename prefix (default: edited)" },
],
examples: [
'bl image edit --image ./photo.png --prompt "把背景换成海滩"',
'bl image edit --image https://example.com/logo.png --prompt "Change color to blue" --n 3',
'bl image edit --image ./a.png --image ./b.png --prompt "把两张图合并成一张拼图"',
'bl image edit --image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
],
async run(config: Config, flags: GlobalFlags) {
// Normalize --image to string array (supports both single and repeated flags)
let rawImages: string[] = [];
if (Array.isArray(flags.image)) {
rawImages = flags.image as string[];
} else if (typeof flags.image === "string") {
rawImages = [flags.image];
}
if (rawImages.length === 0) {
failIfMissing("image", "bl image edit --image <url> --prompt <text>");
}
let prompt = flags.prompt as string | undefined;
if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: "Enter your edit instruction:",
});
if (!hint) {
process.stderr.write("Image editing cancelled.\n");
process.exit(1);
}
prompt = hint;
} else {
failIfMissing("prompt", "bl image edit --image <url> --prompt <text>");
}
}
const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0";
// Auto-upload local files (resolve all images in parallel)
const credential = await resolveCredential(config);
const resolvedImages = await Promise.all(
rawImages.map((img) => resolveFileUrl(img, credential.token, model)),
);
const n = (flags.n as number) ?? 1;
// Determine prompt_extend
let promptExtend: boolean | undefined;
if (flags.noPromptExtend === true) {
promptExtend = false;
} else if (flags.promptExtend === true) {
promptExtend = true;
} else {
promptExtend = true; // default on for qwen-image
}
// Build content: all images first, then text prompt
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
(u: string) => ({ image: u }),
);
contentItems.push({ text: prompt! });
const body: DashScopeImageRequest = {
model,
input: {
messages: [
{
role: "user",
content: contentItems,
},
],
},
parameters: {
size: resolveImageSize(flags.size as string | undefined, true),
n,
seed: flags.seed as number | undefined,
prompt_extend: promptExtend,
watermark: flags.watermark === true ? true : undefined,
negative_prompt: (flags.negativePrompt as string) || undefined,
},
};
// Remove undefined parameters
stripUndefined(body.parameters as Record<string, unknown>);
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
if (!config.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: sync] [Images: ${resolvedImages.length}]\n`);
}
const url = imageSyncEndpoint(config.baseUrl);
const concurrent = getConcurrency(flags);
const results = await runConcurrent(concurrent, config, () =>
requestJson<DashScopeImageSyncResponse>(config, {
url,
method: "POST",
body,
}),
);
// Extract image URLs from all responses
const imageUrls = results
.flatMap((r) => r.output.choices || [])
.flatMap((c) => c.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
if (imageUrls.length === 0) {
throw new BailianError("Edit completed but no images returned.", ExitCode.GENERAL);
}
const outDir = resolveOutputDir(config, {
flagDir: flags.outDir as string | undefined,
subDir: flags.outDir ? undefined : "images",
});
const prefix =
(flags.outPrefix as string) || generateFilename("edited", flags?.prompt as string);
// Parallel download all images
const items =
imageUrls.length > 1
? imageUrls.map((url, i) => {
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
return { url, destPath: join(outDir, filename) };
})
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
const saved = await downloadParallel(items, downloadFile, { quiet: config.quiet });
if (config.quiet) {
emitBare(saved.join("\n"));
} else {
emitResult({ urls: imageUrls, saved, total: imageUrls.length }, format);
}
},
});
+307
View File
@@ -0,0 +1,307 @@
import {
defineCommand,
requestJson,
imageEndpoint,
imageSyncEndpoint,
taskEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
resolveOutputDir,
isInteractive,
type DashScopeImageRequest,
type DashScopeImageSyncResponse,
BailianError,
ExitCode,
type DashScopeAsyncResponse,
type OutputFormat,
type DashScopeTaskResponse,
generateFilename,
} from "bailian-cli-core";
import { poll } from "../../utils/polling.ts";
import { downloadFile } from "../../utils/download.ts";
import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { resolveImageSize } from "../../utils/image-size.ts";
import { join } from "path";
// qwen-image-2.0 series uses the sync multimodal-generation endpoint
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
}
export default defineCommand({
name: "image generate",
description: "Generate images (Qwen-Image / wan2.x)",
apiDocs: "/best-practice/wanx/text-to-image",
usage: "bl image generate --prompt <text> [flags]",
options: [
{ flag: "--prompt <text>", description: "Image description", required: true },
{ flag: "--model <model>", description: "Model ID (default: qwen-image-2.0)" },
{
flag: "--size <W*H>",
description: "Image size: ratio (3:4, 16:9, 1:1) or pixels (2048*2048)",
},
{
flag: "--n <count>",
description: "Number of images per request (default: 1, max: 6)",
type: "number",
},
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{
flag: "--negative-prompt <text>",
description: "Negative prompt to exclude unwanted content",
},
{
flag: "--prompt-extend",
description: "Automatically extend prompt for better results (default: true for qwen-image)",
},
{ flag: "--no-prompt-extend", description: "Disable prompt extend" },
{ flag: "--watermark", description: "Add watermark to generated images" },
{
flag: "--no-wait",
description: "Return task ID immediately without waiting (async models only)",
},
{ flag: "--out-dir <dir>", description: "Download images to directory" },
{ flag: "--out-prefix <prefix>", description: "Filename prefix (default: image)" },
{
flag: "--poll-interval <seconds>",
description: "Polling interval when waiting (default: 3)",
type: "number",
},
],
examples: [
'bl image generate --prompt "一只穿太空服的猫在火星上"',
'bl image generate --prompt "Logo design" --n 3 --out-dir ./generated/',
'bl image generate --prompt "Mountain landscape" --size 2688*1536',
'bl image generate --prompt "A castle" --seed 42 --no-prompt-extend',
'bl image generate --prompt "sunset" --model wan2.6-t2i --no-wait --quiet',
'bl image generate --prompt "Pro quality" --model qwen-image-2.0-pro',
'bl image generate --prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
],
async run(config: Config, flags: GlobalFlags) {
let prompt = (flags.prompt ?? (flags._positional as string[] | undefined)?.[0]) as
| string
| undefined;
if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: "Enter your image prompt:",
});
if (!hint) {
process.stderr.write("Image generation cancelled.\n");
process.exit(1);
}
prompt = hint;
} else {
failIfMissing("prompt", "bl image generate --prompt <text>");
}
}
const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0";
const useSync = isSyncModel(model);
const defaultSize = useSync ? "1:1" : "1:1";
const sizeInput = (flags.size as string) || defaultSize;
const size = resolveImageSize(sizeInput, useSync);
const n = (flags.n as number) ?? 1;
const concurrent = getConcurrency(flags);
// Determine prompt_extend: default true for qwen-image, undefined for others
let promptExtend: boolean | undefined;
if (flags.noPromptExtend === true) {
promptExtend = false;
} else if (flags.promptExtend === true) {
promptExtend = true;
} else if (useSync) {
promptExtend = true; // qwen-image default
}
const body: DashScopeImageRequest = {
model,
input: {
messages: [{ role: "user", content: [{ text: prompt! }] }],
},
parameters: {
size,
n,
seed: flags.seed as number | undefined,
prompt_extend: promptExtend,
watermark: flags.watermark === true ? true : undefined,
negative_prompt: (flags.negativePrompt as string) || undefined,
},
};
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
return;
}
if (!config.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
}
if (useSync) {
await handleSyncMode(config, model, body, flags, format, concurrent);
} else {
await handleAsyncMode(config, model, body, flags, format, concurrent);
}
},
});
// ---- Sync mode: qwen-image-2.0 series ----
async function handleSyncMode(
config: Config,
_model: string,
body: DashScopeImageRequest,
flags: GlobalFlags,
format: string,
concurrent: number,
): Promise<void> {
const url = imageSyncEndpoint(config.baseUrl);
const results = await runConcurrent(concurrent, config, () =>
requestJson<DashScopeImageSyncResponse>(config, { url, method: "POST", body }),
);
const imageUrls = results
.flatMap((r) => r.output.choices || [])
.flatMap((c) => c.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
if (imageUrls.length === 0) {
throw new BailianError("Generation completed but no images returned.", ExitCode.GENERAL);
}
await saveImages(imageUrls, flags, config, format);
}
// ---- Async mode: wan2.x / qwen-image-plus ----
async function handleAsyncMode(
config: Config,
_model: string,
body: DashScopeImageRequest,
flags: GlobalFlags,
format: string,
concurrent: number,
): Promise<void> {
const url = imageEndpoint(config.baseUrl);
const responses = await runConcurrent(
concurrent,
config,
() => requestJson<DashScopeAsyncResponse>(config, { url, method: "POST", body, async: true }),
"tasks",
);
const taskIds = responses.map((r) => r.output.task_id);
// --no-wait: return all task IDs immediately
if (flags.noWait || config.async) {
emitResult({ task_ids: taskIds }, format as OutputFormat);
return;
}
// Poll all tasks concurrently
const pollInterval = (flags.pollInterval as number) ?? 3;
const pollPromises = taskIds.map((taskId) => {
const pollUrl = taskEndpoint(config.baseUrl, taskId);
return poll<DashScopeTaskResponse>(config, {
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: config.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,
getErrorMessage: (d) => {
const o = (d as DashScopeTaskResponse).output;
return o.message || o.code || undefined;
},
});
});
const results = await Promise.all(pollPromises);
let imageUrls: string[] = [];
for (const result of results) {
if (result.output.choices) {
const urls = result.output.choices
.flatMap((c) => c.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
imageUrls.push(...urls);
}
if (result.output.results) {
const urls = result.output.results.map((r) => r.url).filter(Boolean);
if (urls.length > 0 && imageUrls.length === 0) {
imageUrls.push(...urls);
}
}
}
if (imageUrls.length === 0) {
throw new BailianError("All tasks completed but no images returned.", ExitCode.GENERAL);
}
await saveImages(
imageUrls,
flags,
config,
format,
taskIds.length === 1 ? taskIds[0] : undefined,
taskIds,
);
}
// ---- Shared: download & save ----
async function saveImages(
imageUrls: string[],
flags: GlobalFlags,
config: Config,
format: string,
taskId?: string,
taskIds?: string[],
): Promise<void> {
const outDir = resolveOutputDir(config, {
flagDir: flags.outDir as string | undefined,
subDir: flags.outDir ? undefined : "images",
});
const promptText =
(flags.prompt as string) || (flags._positional as string[] | undefined)?.[0] || "";
const prefix = (flags.outPrefix as string) || generateFilename("image", promptText);
// Parallel download all images
const items =
imageUrls.length > 1
? imageUrls.map((url, i) => {
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
return { url, destPath: join(outDir, filename) };
})
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
const results = await downloadParallel(items, downloadFile, { quiet: config.quiet });
if (config.quiet) {
emitBare(results.join("\n"));
} else {
const output: Record<string, unknown> = {
urls: imageUrls,
saved: results,
total: imageUrls.length,
};
if (taskId) output.task_id = taskId;
if (taskIds && taskIds.length > 1) output.task_ids = taskIds;
emitResult(output, format as OutputFormat);
}
}
+1
View File
@@ -0,0 +1 @@
export { commands } from "./catalog.ts";
@@ -0,0 +1,154 @@
import {
defineCommand,
signRequest,
detectOutputFormat,
type Config,
type GlobalFlags,
type KnowledgeRetrieveRequest,
type KnowledgeRetrieveResponse,
BailianError,
ExitCode,
trackingHeaders,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
const BAILIAN_HOST = "bailian.cn-beijing.aliyuncs.com";
export default defineCommand({
name: "knowledge retrieve",
description: "Retrieve from a Bailian knowledge base (requires AK/SK)",
usage: "bl knowledge retrieve --index-id <id> --query <text> [flags]",
options: [
{ flag: "--index-id <id>", description: "Knowledge base index ID (required)", required: true },
{ flag: "--query <text>", description: "Search query (required)", required: true },
{
flag: "--workspace-id <id>",
description: "Bailian workspace ID (or env BAILIAN_WORKSPACE_ID)",
},
{ flag: "--top-k <n>", description: "Number of results (default: 10)", type: "number" },
{ flag: "--rerank", description: "Enable rerank" },
{ flag: "--rerank-top-n <n>", description: "Rerank top N results", type: "number" },
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (or env)" },
{ flag: "--access-key-secret <key>", description: "Alibaba Cloud Access Key Secret (or env)" },
],
examples: [
'bl knowledge retrieve --index-id idx_xxx --query "如何使用阿里云百炼" --workspace-id ws_xxx',
'bl knowledge retrieve --index-id idx_xxx --query "API限流" --top-k 5 --rerank',
],
async run(config: Config, flags: GlobalFlags) {
const indexId = flags.indexId as string;
if (!indexId) failIfMissing("index-id", "bl knowledge retrieve --index-id <id> --query <text>");
const query = flags.query as string;
if (!query) failIfMissing("query", "bl knowledge retrieve --index-id <id> --query <text>");
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
const workspaceId = (flags.workspaceId as string) || config.workspaceId;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"Knowledge retrieve requires Alibaba Cloud AK/SK.\n" +
"Set via: --access-key-id / --access-key-secret flags,\n" +
" or env: ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET,\n" +
" or config: bl config set access_key_id <key>",
ExitCode.AUTH,
);
}
if (!workspaceId) {
throw new BailianError(
"Knowledge retrieve requires a workspace ID.\n" +
"Set via: --workspace-id flag, or env: BAILIAN_WORKSPACE_ID, or config: bl config set workspace_id <id>",
ExitCode.USAGE,
);
}
const body: KnowledgeRetrieveRequest = {
IndexId: indexId,
Query: query,
};
if (flags.topK !== undefined) body.TopK = flags.topK as number;
if (flags.rerank) body.Rerank = true;
if (flags.rerankTopN !== undefined) body.RerankTopN = flags.rerankTopN as number;
const format = detectOutputFormat(config.output);
const pathname = `/${workspaceId}/index/retrieve`;
if (config.dryRun) {
emitResult(
{
endpoint: `https://${BAILIAN_HOST}${pathname}`,
workspaceId,
request: body,
},
format,
);
return;
}
const bodyStr = JSON.stringify(body);
const headers = signRequest({
accessKeyId,
accessKeySecret,
action: "Retrieve",
version: "2023-12-29",
body: bodyStr,
host: BAILIAN_HOST,
pathname,
});
const url = `https://${BAILIAN_HOST}${pathname}`;
if (config.verbose) {
process.stderr.write(`> POST ${url}\n`);
process.stderr.write(`> AK: ${accessKeyId.slice(0, 8)}...\n`);
}
const timeoutMs = config.timeout * 1000;
const res = await fetch(url, {
method: "POST",
headers: {
...headers,
...trackingHeaders(),
},
body: bodyStr,
signal: AbortSignal.timeout(timeoutMs),
});
if (config.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
const data = (await res.json()) as KnowledgeRetrieveResponse & {
Code?: string;
Message?: string;
};
if (!res.ok || (data.Code && data.Code !== "Success")) {
throw new BailianError(
`Knowledge retrieve failed: ${data.Code || res.status} - ${data.Message || res.statusText}`,
ExitCode.GENERAL,
);
}
if (config.quiet || format === "text") {
const nodes = data.Data?.Nodes || [];
if (nodes.length === 0) {
emitBare("No results found.");
} else {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
emitBare(`[${i + 1}] (score: ${node.Score.toFixed(4)})`);
emitBare(node.Text);
emitBare("");
}
}
} else {
emitResult(data, format);
}
},
});
+81
View File
@@ -0,0 +1,81 @@
import {
defineCommand,
requestJson,
memoryAddEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type MemoryAddRequest,
type MemoryAddResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory add",
description: "Add memory from messages or custom content",
usage: "bl memory add --user-id <id> [--messages <json>] [--content <text>] [flags]",
options: [
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
{
flag: "--messages <json>",
description: 'Messages JSON array: [{"role":"user","content":"..."},...]',
},
{ flag: "--content <text>", description: "Custom content text to memorize" },
{ flag: "--profile-schema <id>", description: "Profile schema ID for user profiling" },
{ flag: "--memory-library-id <id>", description: "Memory library ID (isolate memory space)" },
],
examples: [
'bl memory add --user-id user1 --content "用户喜欢Python编程"',
'bl memory add --user-id user1 --messages \'[{"role":"user","content":"我喜欢旅行"}]\'',
'bl memory add --user-id user1 --content "住在北京" --profile-schema schema_xxx',
],
async run(config: Config, flags: GlobalFlags) {
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", "bl memory add --user-id <id>");
const body: MemoryAddRequest = { user_id: userId };
if (flags.messages) {
try {
body.messages = JSON.parse(flags.messages as string);
} catch {
process.stderr.write("Error: --messages must be valid JSON array\n");
process.exit(1);
}
}
if (flags.content) {
body.custom_content = flags.content as string;
}
if (!body.messages && !body.custom_content) {
process.stderr.write("Error: at least one of --messages or --content is required\n");
process.exit(1);
}
if (flags.profileSchema) body.profile_schema = flags.profileSchema as string;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ endpoint: memoryAddEndpoint(config.baseUrl), request: body }, format);
return;
}
const url = memoryAddEndpoint(config.baseUrl);
const response = await requestJson<MemoryAddResponse>(config, {
url,
method: "POST",
body,
});
if (config.quiet || format === "text") {
const ids = response.memory_ids?.join(", ") || "none";
emitBare(`Memory added. IDs: ${ids}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,50 @@
import {
defineCommand,
requestJson,
memoryNodeEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory delete",
description: "Delete a memory node",
usage: "bl memory delete --node-id <id> --user-id <id>",
options: [
{ flag: "--node-id <id>", description: "Memory node ID (required)", required: true },
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
{ flag: "--memory-library-id <id>", description: "Memory library ID (non-default library)" },
],
examples: ["bl memory delete --node-id node_xxx --user-id user1"],
async run(config: Config, flags: GlobalFlags) {
const nodeId = flags.nodeId as string;
if (!nodeId) failIfMissing("node-id", "bl memory delete --node-id <id> --user-id <id>");
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", "bl memory delete --node-id <id> --user-id <id>");
const format = detectOutputFormat(config.output);
const params = new URLSearchParams({ user_id: userId });
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId as string);
const url = `${memoryNodeEndpoint(config.baseUrl, nodeId)}?${params.toString()}`;
if (config.dryRun) {
emitResult({ endpoint: url, method: "DELETE" }, format);
return;
}
const response = await requestJson<{ request_id: string }>(config, {
url,
method: "DELETE",
});
if (config.quiet || format === "text") {
emitBare(`Memory node ${nodeId} deleted.`);
} else {
emitResult(response, format);
}
},
});
+65
View File
@@ -0,0 +1,65 @@
import {
defineCommand,
requestJson,
memoryListEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type MemoryNodeListResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory list",
description: "List memory nodes for a user",
usage: "bl memory list --user-id <id> [flags]",
options: [
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
{ flag: "--page-size <n>", description: "Results per page (default: 10)", type: "number" },
{ flag: "--page <n>", description: "Page number (default: 1)", type: "number" },
{ flag: "--memory-library-id <id>", description: "Memory library ID" },
],
examples: [
"bl memory list --user-id user1",
"bl memory list --user-id user1 --page-size 20 --page 2",
],
async run(config: Config, flags: GlobalFlags) {
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", "bl memory list --user-id <id>");
const format = detectOutputFormat(config.output);
const params = new URLSearchParams();
params.set("user_id", userId);
if (flags.pageSize !== undefined) params.set("page_size", String(flags.pageSize as number));
if (flags.page !== undefined) params.set("page_num", String(flags.page as number));
if (flags.memoryLibraryId) params.set("memory_library_id", flags.memoryLibraryId as string);
const url = `${memoryListEndpoint(config.baseUrl)}?${params.toString()}`;
if (config.dryRun) {
emitResult({ endpoint: url, method: "GET" }, format);
return;
}
const response = await requestJson<MemoryNodeListResponse>(config, {
url,
method: "GET",
});
if (config.quiet || format === "text") {
if (!response.memory_nodes || response.memory_nodes.length === 0) {
emitBare("No memory nodes found.");
} else {
for (const node of response.memory_nodes) {
emitBare(`[${node.memory_node_id}] ${node.content}`);
}
if (response.total !== undefined) {
emitBare(`\nTotal: ${response.total}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,69 @@
import {
defineCommand,
requestJson,
profileSchemaEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type ProfileSchemaCreateRequest,
type ProfileSchemaCreateResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory profile create",
description: "Create a user profile schema for memory profiling",
usage: "bl memory profile create --name <name> --attributes <json> [flags]",
options: [
{ flag: "--name <name>", description: "Schema name (required)", required: true },
{ flag: "--description <text>", description: "Schema description" },
{
flag: "--attributes <json>",
description: 'Attributes JSON array: [{"name":"age","description":"年龄"}]',
required: true,
},
],
examples: [
'bl memory profile create --name "user_basic" --attributes \'[{"name":"age","description":"年龄"},{"name":"hobby","description":"爱好"}]\'',
],
async run(config: Config, flags: GlobalFlags) {
const name = flags.name as string;
if (!name) failIfMissing("name", "bl memory profile create --name <name> --attributes <json>");
const attrStr = flags.attributes as string;
if (!attrStr)
failIfMissing("attributes", "bl memory profile create --name <name> --attributes <json>");
let attributes;
try {
attributes = JSON.parse(attrStr);
} catch {
process.stderr.write("Error: --attributes must be valid JSON array\n");
process.exit(1);
}
const body: ProfileSchemaCreateRequest = { name, attributes };
if (flags.description) body.description = flags.description as string;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ endpoint: profileSchemaEndpoint(config.baseUrl), request: body }, format);
return;
}
const url = profileSchemaEndpoint(config.baseUrl);
const response = await requestJson<ProfileSchemaCreateResponse>(config, {
url,
method: "POST",
body,
});
if (config.quiet || format === "text") {
emitBare(`Profile schema created: ${response.profile_schema_id}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,56 @@
import {
defineCommand,
requestJson,
userProfileEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type UserProfileResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory profile get",
description: "Get user profile by schema ID and user ID",
usage: "bl memory profile get --schema-id <id> --user-id <id>",
options: [
{ flag: "--schema-id <id>", description: "Profile schema ID (required)", required: true },
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
],
examples: ["bl memory profile get --schema-id schema_xxx --user-id user1"],
async run(config: Config, flags: GlobalFlags) {
const schemaId = flags.schemaId as string;
if (!schemaId)
failIfMissing("schema-id", "bl memory profile get --schema-id <id> --user-id <id>");
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", "bl memory profile get --schema-id <id> --user-id <id>");
const format = detectOutputFormat(config.output);
const params = new URLSearchParams({ user_id: userId });
const url = `${userProfileEndpoint(config.baseUrl, schemaId)}?${params.toString()}`;
if (config.dryRun) {
emitResult({ endpoint: url, method: "GET" }, format);
return;
}
const response = await requestJson<UserProfileResponse>(config, {
url,
method: "GET",
});
if (config.quiet || format === "text") {
if (response.profile?.attributes) {
for (const attr of response.profile.attributes) {
emitBare(`${attr.name}: ${attr.value ?? "(empty)"}`);
}
} else {
emitBare("No profile data found.");
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,89 @@
import {
defineCommand,
requestJson,
memorySearchEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type MemorySearchRequest,
type MemorySearchResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory search",
description: "Search memory nodes by query or messages",
usage: "bl memory search --user-id <id> [--query <text>] [flags]",
options: [
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
{ flag: "--query <text>", description: "Search query text" },
{ flag: "--messages <json>", description: "Messages JSON array for context-based search" },
{
flag: "--top-k <n>",
description: "Number of results to return (default: 10)",
type: "number",
},
{ flag: "--memory-library-id <id>", description: "Memory library ID" },
],
examples: [
'bl memory search --user-id user1 --query "编程偏好"',
'bl memory search --user-id user1 --messages \'[{"role":"user","content":"推荐一本书"}]\' --top-k 5',
],
async run(config: Config, flags: GlobalFlags) {
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", "bl memory search --user-id <id>");
const body: MemorySearchRequest = { user_id: userId };
if (flags.query) body.query = flags.query as string;
if (flags.messages) {
try {
body.messages = JSON.parse(flags.messages as string);
} catch {
process.stderr.write("Error: --messages must be valid JSON array\n");
process.exit(1);
}
}
// API requires messages; if only query is given, wrap it as a user message
if (!body.messages && body.query) {
body.messages = [{ role: "user", content: body.query }];
}
if (!body.query && !body.messages) {
process.stderr.write("Error: at least one of --query or --messages is required\n");
process.exit(1);
}
if (flags.topK !== undefined) body.top_k = flags.topK as number;
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ endpoint: memorySearchEndpoint(config.baseUrl), request: body }, format);
return;
}
const url = memorySearchEndpoint(config.baseUrl);
const response = await requestJson<MemorySearchResponse>(config, {
url,
method: "POST",
body,
});
if (config.quiet || format === "text") {
if (!response.memory_nodes || response.memory_nodes.length === 0) {
emitBare("No memory nodes found.");
} else {
for (const node of response.memory_nodes) {
emitBare(`[${node.memory_node_id}] ${node.content}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,70 @@
import {
defineCommand,
requestJson,
memoryNodeEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type MemoryNodeUpdateRequest,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "memory update",
description: "Update a memory node content",
usage: "bl memory update --node-id <id> --user-id <id> --content <text>",
options: [
{ flag: "--node-id <id>", description: "Memory node ID (required)", required: true },
{ flag: "--user-id <id>", description: "User ID (required)", required: true },
{
flag: "--content <text>",
description: "New content for the memory node (required)",
required: true,
},
{ flag: "--memory-library-id <id>", description: "Memory library ID (non-default library)" },
],
examples: ['bl memory update --node-id node_xxx --user-id user1 --content "更新后的记忆内容"'],
async run(config: Config, flags: GlobalFlags) {
const nodeId = flags.nodeId as string;
if (!nodeId)
failIfMissing("node-id", "bl memory update --node-id <id> --user-id <id> --content <text>");
const userId = flags.userId as string;
if (!userId)
failIfMissing("user-id", "bl memory update --node-id <id> --user-id <id> --content <text>");
const content = flags.content as string;
if (!content)
failIfMissing("content", "bl memory update --node-id <id> --user-id <id> --content <text>");
const body: MemoryNodeUpdateRequest = {
user_id: userId,
custom_content: content,
};
if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{ endpoint: memoryNodeEndpoint(config.baseUrl, nodeId), method: "PATCH", request: body },
format,
);
return;
}
const url = memoryNodeEndpoint(config.baseUrl, nodeId);
const response = await requestJson<{ request_id: string }>(config, {
url,
method: "PATCH",
body,
});
if (config.quiet || format === "text") {
emitBare(`Memory node ${nodeId} updated.`);
} else {
emitResult(response, format);
}
},
});
+322
View File
@@ -0,0 +1,322 @@
import { writeFileSync } from "fs";
import {
defineCommand,
request,
chatEndpoint,
parseSSE,
detectOutputFormat,
type Config,
type GlobalFlags,
type ChatMessage,
type ChatMessageContent,
type ChatRequest,
type StreamChunk,
isInteractive,
resolveFileUrl,
} from "bailian-cli-core";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
import { resolveOutputDir, resolveCredential } from "bailian-cli-core";
const OMNI_VOICES = ["Chelsie", "Cherry", "Ethan", "Serena", "Tina"];
/**
* Build a standard WAV file header for PCM 16-bit mono 24kHz audio.
*/
function buildWavHeader(dataLength: number): Buffer {
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + dataLength, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16); // PCM chunk size
header.writeUInt16LE(1, 20); // format = PCM
header.writeUInt16LE(1, 22); // channels = 1 (mono)
header.writeUInt32LE(24000, 24); // sample rate
header.writeUInt32LE(48000, 28); // byte rate (24000 * 1 * 2)
header.writeUInt16LE(2, 32); // block align (channels * bitsPerSample / 8)
header.writeUInt16LE(16, 34); // bits per sample
header.write("data", 36);
header.writeUInt32LE(dataLength, 40);
return header;
}
export default defineCommand({
name: "omni",
description: "Multimodal chat with text + audio output (Qwen-Omni)",
apiDocs: "/model-studio/qwen-omni",
usage: "bl omni --message <text> [flags]",
options: [
{
flag: "--message <text>",
description: "Message text (repeatable, prefix role: to set role)",
required: true,
type: "array",
},
{ flag: "--model <model>", description: "Model ID (default: qwen3.5-omni-plus)" },
{ flag: "--system <text>", description: "System prompt" },
{ flag: "--image <url>", description: "Image URL or local file (repeatable)", type: "array" },
{ flag: "--audio <url>", description: "Audio URL or local file (repeatable)", type: "array" },
{
flag: "--video <url>",
description: "Video file URL / local path, or comma-separated frame URLs",
type: "array",
},
{
flag: "--voice <voice>",
description: `Output voice (default: Cherry). Options: ${OMNI_VOICES.join(", ")}`,
},
{ flag: "--audio-format <fmt>", description: "Audio output format (default: wav)" },
{ flag: "--audio-out <path>", description: "Save audio to file (default: auto-generate)" },
{ flag: "--text-only", description: "Output text only, no audio generation" },
{ flag: "--max-tokens <n>", description: "Maximum tokens to generate", type: "number" },
{ flag: "--temperature <n>", description: "Sampling temperature (0.0, 2.0]", type: "number" },
],
examples: [
'bl omni --message "你好,你是谁?"',
'bl omni --message "描述这张图片" --image ./photo.jpg',
'bl omni --message "这段音频在说什么?" --audio https://example.com/audio.wav',
'bl omni --message "总结这个视频" --video https://example.com/video.mp4',
'bl omni --message "这个视频讲了什么" --video ./local-video.mp4 --text-only',
'bl omni --message "用四川话回答:今天天气怎么样" --voice Serena',
'bl omni --message "Hello" --text-only --output json',
'bl omni --message "朗读这段话" --audio-out greeting.wav',
],
async run(config: Config, flags: GlobalFlags) {
// --- Parse messages ---
let userMessages: string[] = [];
if (flags.message) {
userMessages = flags.message as string[];
}
if (userMessages.length === 0) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "Enter your message:" });
if (!hint) {
process.stderr.write("Omni chat cancelled.\n");
process.exit(1);
}
userMessages = [hint];
} else {
failIfMissing("message", "bl text omni --message <text>");
}
}
const model = (flags.model as string) || config.defaultOmniModel || "qwen3.5-omni-plus";
const voice = (flags.voice as string) || "Cherry";
const audioFormat = (flags.audioFormat as string) || "wav";
const textOnly = flags.textOnly === true;
const format = detectOutputFormat(config.output);
// --- Build messages array ---
const allMessages: ChatMessage[] = [];
if (flags.system) {
allMessages.push({ role: "system", content: flags.system as string });
}
// Build multimodal content for user messages
const validRoles = new Set(["system", "user", "assistant"]);
for (const m of userMessages) {
const colonIdx = m.indexOf(":");
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
if (validRoles.has(maybeRole)) {
const content = m.slice(colonIdx + 1);
if (maybeRole === "system") {
allMessages.push({ role: "system", content });
} else {
allMessages.push({ role: maybeRole as "user" | "assistant", content });
}
} else {
allMessages.push({ role: "user", content: m });
}
}
// Attach multimodal inputs to the last user message
const rawImageUrls = (flags.image as string[] | undefined) || [];
const rawAudioUrls = (flags.audio as string[] | undefined) || [];
const rawVideoUrls = (flags.video as string[] | undefined) || [];
// Auto-upload local files
const imageUrls: string[] = [];
const audioUrls: string[] = [];
const videoUrls: string[] = [];
const needsResolve =
rawImageUrls.length > 0 || rawAudioUrls.length > 0 || rawVideoUrls.length > 0;
if (needsResolve) {
const credential = await resolveCredential(config);
for (const u of rawImageUrls) {
const resolved = await resolveFileUrl(u, credential.token, model);
imageUrls.push(resolved);
}
for (const u of rawAudioUrls) {
const resolved = await resolveFileUrl(u, credential.token, model);
audioUrls.push(resolved);
}
for (const u of rawVideoUrls) {
// Detect: comma-separated = frame list, otherwise single video URL/file
if (u.includes(",")) {
// Legacy frame list mode
const frames = u
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Resolve each frame URL
for (const f of frames) {
const resolved = await resolveFileUrl(f, credential.token, model);
videoUrls.push(`frame:${resolved}`);
}
} else {
const resolved = await resolveFileUrl(u, credential.token, model);
videoUrls.push(resolved);
}
}
}
if (imageUrls.length > 0 || audioUrls.length > 0 || videoUrls.length > 0) {
// Find last user message and convert to multimodal content array
for (let i = allMessages.length - 1; i >= 0; i--) {
if (allMessages[i].role === "user") {
const existingContent = allMessages[i].content;
const contentArray: ChatMessageContent[] = [];
// Keep existing text
if (typeof existingContent === "string") {
contentArray.push({ type: "text", text: existingContent });
} else if (Array.isArray(existingContent)) {
contentArray.push(...existingContent);
}
// Add image URLs
for (const url of imageUrls) {
contentArray.push({ type: "image_url", image_url: { url } });
}
// Add audio URLs
for (const url of audioUrls) {
contentArray.push({ type: "audio_url", audio_url: { url } });
}
// Add video URLs: frame:xxx are frame list items, others are direct video URLs
const frameItems = videoUrls.filter((v) => v.startsWith("frame:")).map((v) => v.slice(6));
const directVideoUrls = videoUrls.filter((v) => !v.startsWith("frame:"));
if (frameItems.length > 0) {
contentArray.push({ type: "video", video: frameItems });
}
for (const url of directVideoUrls) {
contentArray.push({ type: "video_url", video_url: { url } });
}
allMessages[i] = { role: "user", content: contentArray };
break;
}
}
}
// --- Build request body ---
const body: ChatRequest = {
model,
messages: allMessages,
stream: true,
stream_options: { include_usage: true },
};
if (!textOnly) {
body.modalities = ["text", "audio"];
body.audio = { voice, format: audioFormat };
}
if (flags.maxTokens !== undefined) body.max_tokens = flags.maxTokens as number;
if (flags.temperature !== undefined) body.temperature = flags.temperature as number;
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
if (!config.quiet) {
const modeLabel = textOnly ? "text-only" : `text+audio, voice: ${voice}`;
process.stderr.write(`[Model: ${model}] [${modeLabel}]\n`);
}
// --- Stream request ---
const url = chatEndpoint(config.baseUrl);
const res = await request(config, {
url,
method: "POST",
body,
stream: true,
});
let textContent = "";
let audioBase64 = "";
const isTTY = process.stdout.isTTY;
const resultOut = process.stdout;
for await (const event of parseSSE(res)) {
if (event.data === "[DONE]") break;
try {
const parsed = JSON.parse(event.data) as StreamChunk;
for (const choice of parsed.choices) {
const delta = choice.delta;
// Collect text content
if (delta.content) {
textContent += delta.content;
if (isTTY) {
resultOut.write(delta.content);
}
}
// Collect audio data
if (delta.audio?.data) {
audioBase64 += delta.audio.data;
}
}
} catch {
// Skip unparseable chunks
}
}
if (isTTY && textContent) {
resultOut.write("\n");
}
// --- Save audio ---
let audioSaved: string | undefined;
if (audioBase64 && !textOnly) {
const pcmBuffer = Buffer.from(audioBase64, "base64");
const wavHeader = buildWavHeader(pcmBuffer.length);
const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]);
let destPath = flags.audioOut as string | undefined;
if (!destPath) {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { join } = await import("path");
const destDir = resolveOutputDir(config, { subDir: "omni" });
const timestamp = Date.now();
destPath = join(destDir, `omni_${timestamp}.wav`);
}
writeFileSync(destPath, wavBuffer);
audioSaved = destPath;
if (!config.quiet) {
process.stderr.write(`Audio saved: ${destPath}\n`);
}
}
// --- Emit structured result ---
if (!isTTY || format === "json") {
const result: Record<string, unknown> = { content: textContent };
if (audioSaved) {
result.audio_saved = audioSaved;
result.voice = voice;
}
emitResult(result, format);
}
},
});
@@ -0,0 +1,24 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import type { PipelineDefinition } from "../../pipeline/types.ts";
export async function loadPipelineFile(filePath: string): Promise<PipelineDefinition> {
const raw = await readFile(filePath, "utf-8").catch((err: Error) => {
process.stderr.write(`Error: cannot read pipeline file: ${err.message}\n`);
process.exit(2);
});
const ext = extname(filePath).toLowerCase();
let parsed: unknown;
if (ext === ".yaml" || ext === ".yml") {
const { parse: parseYaml } = await import("yaml");
parsed = parseYaml(raw);
} else {
try {
parsed = JSON.parse(raw);
} catch {
const { parse: parseYaml } = await import("yaml");
parsed = parseYaml(raw);
}
}
return parsed as PipelineDefinition;
}
+149
View File
@@ -0,0 +1,149 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { initPipelineSteps } from "../../pipeline/init.ts";
import { executePipeline, streamPipelineEvents } from "../../pipeline/executor.ts";
import type { PipelineLifecycleEvent } from "../../pipeline/types.ts";
import { loadPipelineFile } from "./load-file.ts";
export default defineCommand({
name: "pipeline run",
description: "Run a pipeline workflow definition",
usage: "bl pipeline run <file> [flags]",
options: [
{ flag: "--input <json>", description: "Runtime input as inline JSON" },
{ flag: "--input-file <path>", description: "Runtime input from a JSON file" },
{
flag: "--concurrency <n>",
description: "Max parallel steps (default: 1)",
type: "number",
},
{ flag: "--events <format>", description: "Emit lifecycle events: jsonl" },
{
flag: "--timeout <seconds>",
description: "Default step timeout in seconds",
type: "number",
},
],
examples: [
'bl pipeline run workflow.yaml --input \'{"brief":"hello"}\'',
"bl pipeline run workflow.json --input-file inputs.json --concurrency 3",
"bl pipeline run workflow.yaml --dry-run",
"bl pipeline run workflow.json --events jsonl",
"bl pipeline run workflow.yaml --output json",
],
async run(config: Config, flags: GlobalFlags) {
const file = ((flags._positional as string[] | undefined) ?? [])[0] as string | undefined;
if (!file) {
process.stderr.write("Error: pipeline file is required\nUsage: bl pipeline run <file>\n");
process.exit(2);
}
initPipelineSteps();
const eventsFormat = flags.events as string | undefined;
if (eventsFormat !== undefined && eventsFormat !== "jsonl") {
process.stderr.write(
`Error: unsupported --events format: ${eventsFormat}. Supported: jsonl\n`,
);
process.exit(2);
}
const filePath = resolve(file);
const pipeline = await loadPipelineFile(filePath);
const runtimeInput = await resolveRuntimeInput(flags);
const basePath = dirname(filePath);
if (eventsFormat === "jsonl") {
for await (const event of streamPipelineEvents(pipeline, runtimeInput, {
concurrency: flags.concurrency as number | undefined,
basePath,
dryRun: flags.dryRun,
timeoutSeconds: flags.timeout as number | undefined,
})) {
process.stdout.write(JSON.stringify(event) + "\n");
}
return;
}
const report = await executePipeline(pipeline, runtimeInput, {
concurrency: flags.concurrency as number | undefined,
basePath,
dryRun: flags.dryRun,
timeoutSeconds: flags.timeout as number | undefined,
onEvent: flags.verbose ? logEvent : undefined,
});
if (config.output === "json") {
emitResult(report, "json");
} else {
printTextReport(report);
}
if (report.status === "failed") process.exitCode = 1;
},
});
async function resolveRuntimeInput(flags: GlobalFlags): Promise<Record<string, unknown>> {
const inputJson = flags.input as string | undefined;
const inputFile = flags.inputFile as string | undefined;
if (inputJson && inputFile) {
process.stderr.write("Error: use --input or --input-file, not both\n");
process.exit(2);
}
if (inputJson) return JSON.parse(inputJson) as Record<string, unknown>;
if (inputFile) {
const raw = await readFile(resolve(inputFile), "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
}
return {};
}
function logEvent(event: PipelineLifecycleEvent): void {
const prefix = `[${event.type}]`;
if ("stepCount" in event) {
process.stderr.write(`${prefix} ${event.stepCount} step${event.stepCount === 1 ? "" : "s"}\n`);
return;
}
if ("step" in event && event.step) {
process.stderr.write(`${prefix} ${formatStep(event.step)}\n`);
} else {
process.stderr.write(`${prefix}\n`);
}
}
function formatStep(step: { id: string; type: string; index?: number; total?: number }): string {
const progress =
step.index !== undefined && step.total !== undefined ? `${step.index}/${step.total} ` : "";
return `${progress}${step.id} (${step.type})`;
}
function printTextReport(report: {
status: string;
steps: Array<{
id: string;
type: string;
status: string;
skipReason?: string;
error?: { message: string };
}>;
}): void {
const statusIcon = report.status === "succeeded" ? "+" : report.status === "failed" ? "x" : "~";
process.stdout.write(`\nPipeline ${report.status} [${statusIcon}]\n\n`);
for (const step of report.steps) {
const icon =
step.status === "succeeded"
? "+"
: step.status === "failed"
? "x"
: step.status === "skipped"
? "-"
: "~";
let line = ` [${icon}] ${step.id} (${step.type}) — ${step.status}`;
if (step.skipReason) line += ` (${step.skipReason})`;
if (step.error) line += `: ${step.error.message}`;
process.stdout.write(line + "\n");
}
process.stdout.write("\n");
}
@@ -0,0 +1,52 @@
import { resolve } from "node:path";
import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core";
import { emitResult } from "../../output/output.ts";
import { initPipelineSteps } from "../../pipeline/init.ts";
import { collectPipelineIssues, collectPipelineHints } from "../../pipeline/validation.ts";
import { loadPipelineFile } from "./load-file.ts";
export default defineCommand({
name: "pipeline validate",
description: "Validate a pipeline definition without executing",
usage: "bl pipeline validate <file>",
options: [],
examples: [
"bl pipeline validate workflow.yaml",
"bl pipeline validate workflow.json --output json",
],
async run(config: Config, flags: GlobalFlags) {
const file = ((flags._positional as string[] | undefined) ?? [])[0] as string | undefined;
if (!file) {
process.stderr.write(
"Error: pipeline file is required\nUsage: bl pipeline validate <file>\n",
);
process.exit(2);
}
initPipelineSteps();
const filePath = resolve(file);
const pipeline = await loadPipelineFile(filePath);
const issues = collectPipelineIssues(pipeline);
const hints = issues.length === 0 ? collectPipelineHints(pipeline) : [];
if (config.output === "json") {
emitResult(
{ valid: issues.length === 0, issues, ...(hints.length > 0 ? { hints } : {}) },
"json",
);
if (issues.length > 0) process.exitCode = 1;
} else if (issues.length === 0) {
process.stdout.write("Pipeline definition is valid.\n");
for (const hint of hints) {
process.stderr.write(` hint: ${hint}\n`);
}
} else {
process.stderr.write("Pipeline validation failed:\n");
for (const issue of issues) {
process.stderr.write(` - ${issue}\n`);
}
process.exitCode = 1;
}
},
});
+138
View File
@@ -0,0 +1,138 @@
import {
defineCommand,
mcpWebSearchEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
isInteractive,
McpClient,
} from "bailian-cli-core";
import { createSpinner } from "../../output/progress.ts";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
export default defineCommand({
name: "search web",
description: "Search the web using DashScope MCP WebSearch service",
usage: "bl search web --query <text> [flags]",
options: [
{ flag: "--query <text>", description: "Search query text", required: true },
{ flag: "--count <n>", description: "Number of search results (default: 10)", type: "number" },
{ flag: "--list-tools", description: "List available MCP tools and exit" },
],
examples: [
'bl search web --query "阿里云百炼最新功能"',
'bl search web --query "TypeScript 5.9 new features" --count 5',
'bl search web --query "今日新闻"',
"bl search web --list-tools",
],
async run(config: Config, flags: GlobalFlags) {
const mcpUrl = mcpWebSearchEndpoint(config.baseUrl);
const format = detectOutputFormat(config.output);
// --- List tools mode ---
if (flags.listTools) {
if (config.dryRun) {
emitResult({ endpoint: mcpUrl, action: "tools/list" }, format);
return;
}
const client = new McpClient(config, mcpUrl);
await client.initialize();
const tools = await client.listTools();
emitResult({ tools }, format);
return;
}
// --- Search mode ---
let query = flags.query as string | undefined;
if (!query) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "Enter your search query:" });
if (!hint) {
process.stderr.write("Search cancelled.\n");
process.exit(1);
}
query = hint;
} else {
failIfMissing("query", "bl search web --query <text>");
}
}
if (config.dryRun) {
emitResult(
{
endpoint: mcpUrl,
action: "tools/call",
tool: "bailian_web_search",
arguments: {
query: query!,
count: (flags.count as number) || undefined,
},
},
format,
);
return;
}
// Initialize MCP client
const client = new McpClient(config, mcpUrl);
const spinner = createSpinner("Initializing search...");
if (!config.quiet) spinner.start();
try {
await client.initialize();
if (!config.quiet) spinner.update("Searching...");
// Build tool arguments
const toolArgs: Record<string, unknown> = { query: query! };
if (flags.count) toolArgs.count = flags.count as number;
// Call the search tool
const result = await client.callTool("bailian_web_search", toolArgs);
if (!config.quiet) spinner.stop("Done.");
// Handle error response
if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n");
process.stderr.write(`Search error: ${errText}\n`);
process.exit(1);
}
// Output results — always structured to stdout
if (format === "json") {
emitResult(result, format);
} else {
// Text mode: try to extract pages for human-friendly display
for (const item of result.content) {
if (item.type === "text" && item.text) {
try {
const data = JSON.parse(item.text) as {
pages?: Array<{
title?: string;
url?: string;
snippet?: string;
hostname?: string;
}>;
};
if (data.pages && Array.isArray(data.pages)) {
emitResult({ pages: data.pages, total: data.pages.length }, format);
} else {
emitResult(data, format);
}
} catch {
emitResult({ text: item.text }, format);
}
}
}
}
} catch (error) {
spinner.stop("Failed.");
throw error;
}
},
});
@@ -0,0 +1,249 @@
import { writeFileSync } from "fs";
import {
BailianError,
defineCommand,
ExitCode,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeASRRequest,
type DashScopeASRTaskResult,
type DashScopeAsyncResponse,
resolveFileUrl,
resolveCredential,
trackingHeaders,
stripUndefined,
taskEndpoint,
requestJson,
type OutputFormat,
speechRecognizeEndpoint,
} from "bailian-cli-core";
import { poll } from "../../utils/polling.ts";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "speech recognize",
description: "Recognize speech from audio files (FunAudio-ASR)",
apiDocs: "/developer-reference/recording-file-recognition",
usage: "bl speech recognize --url <audio-url> [flags]",
options: [
{
flag: "--url <url>",
description: "Audio file URL or local file path (repeatable, max 100)",
required: true,
type: "array",
},
{ flag: "--model <model>", description: "Model ID (default: fun-asr)" },
{ flag: "--language <lang>", description: "Language hint (e.g. zh, en, ja)" },
{ flag: "--diarization", description: "Enable automatic speaker diarization" },
{
flag: "--speaker-count <n>",
description: "Expected number of speakers (requires --diarization)",
type: "number",
},
{ flag: "--vocabulary-id <id>", description: "Hot-word vocabulary ID for improved accuracy" },
{ flag: "--channel-id <n>", description: "Audio channel ID (default: 0)", type: "number" },
{ flag: "--out <path>", description: "Save full transcription result to JSON file" },
{ flag: "--no-wait", description: "Return task ID immediately without polling" },
{
flag: "--poll-interval <seconds>",
description: "Polling interval in seconds (default: 2)",
type: "number",
},
],
examples: [
"bl speech recognize --url https://example.com/audio.mp3",
"bl speech recognize --url https://example.com/a.mp3 --url https://example.com/b.mp3",
"bl speech recognize --url https://example.com/meeting.wav --diarization --speaker-count 3",
"bl speech recognize --url https://example.com/audio.mp3 --language zh",
"bl speech recognize --url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
"bl speech recognize --url https://example.com/audio.mp3 --out result.json",
"bl speech recognize --url https://example.com/audio.mp3 --no-wait --quiet",
],
async run(config: Config, flags: GlobalFlags) {
// Normalize --url to string[] (supports both single and repeated flags)
let rawUrls: string[] = [];
if (Array.isArray(flags.url)) {
rawUrls = flags.url as string[];
} else if (typeof flags.url === "string") {
rawUrls = [flags.url];
}
if (rawUrls.length === 0) {
failIfMissing("url", "bl speech recognize --url <audio-url>");
}
// Strict validation: --speaker-count requires --diarization
const speakerCount = flags.speakerCount as number | undefined;
const diarization = flags.diarization === true;
if (speakerCount !== undefined && !diarization) {
throw new BailianError(
"--speaker-count requires --diarization to be enabled.\nHint: Add --diarization flag to enable speaker separation.",
ExitCode.USAGE,
);
}
const model = (flags.model as string) || "fun-asr";
const format = detectOutputFormat(config.output);
// Auto-upload local files in parallel
const credential = await resolveCredential(config);
const resolvedUrls = await Promise.all(
rawUrls.map((u) => resolveFileUrl(u, credential.token, model)),
);
const channelId = flags.channelId as number | undefined;
const language = flags.language as string | undefined;
const vocabularyId = flags.vocabularyId as string | undefined;
const body: DashScopeASRRequest = {
model,
input: {
file_urls: resolvedUrls,
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
language_hints: language ? [language] : undefined,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
},
};
// Remove undefined parameter fields
stripUndefined(body.parameters as Record<string, unknown>);
if (config.dryRun) {
emitResult({ request: body, mode: "async" }, format);
return;
}
if (!config.quiet) {
process.stderr.write(`[Model: ${model}] [Mode: async] [Files: ${resolvedUrls.length}]\n`);
}
const url = speechRecognizeEndpoint(config.baseUrl);
await handleAsyncMode(config, url, body, flags, format, resolvedUrls.length);
},
});
async function handleAsyncMode(
config: Config,
url: string,
body: DashScopeASRRequest,
flags: GlobalFlags,
format: OutputFormat,
fileCount: number,
): Promise<void> {
// Submit async task (always required for fun-asr)
const response = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
});
const taskId = response.output.task_id;
// --no-wait: return task ID immediately
if (flags.noWait || config.async) {
emitResult({ task_id: taskId }, format);
return;
}
// Poll until completion
const pollInterval = (flags.pollInterval as number) ?? 2;
const pollUrl = taskEndpoint(config.baseUrl, taskId);
const result = await poll<DashScopeASRTaskResult>(config, {
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: config.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,
getErrorMessage: (d) => {
const o = (d as DashScopeASRTaskResult).output;
return (o as unknown as Record<string, unknown>).message as string | undefined;
},
});
const results = result.output.results ?? [];
if (results.length === 0) {
emitResult({ task_id: taskId, status: result.output.task_status }, format);
return;
}
// Collect all transcription data for --out
const allTransData: Record<string, unknown>[] = [];
for (let i = 0; i < results.length; i++) {
const subResult = results[i]!;
const isMulti = fileCount > 1;
if (isMulti) {
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
}
if (subResult.subtask_status === "FAILED") {
const errMsg = subResult.message ?? subResult.code ?? "unknown error";
process.stdout.write(`[FAILED] ${subResult.file_url ?? ""}${errMsg}\n`);
if (isMulti) process.stdout.write("\n");
continue;
}
if (!subResult.transcription_url) {
if (isMulti) process.stdout.write("\n");
continue;
}
// Fetch transcription JSON
const transRes = await fetch(subResult.transcription_url, {
headers: trackingHeaders(),
});
if (!transRes.ok) {
throw new BailianError(
`Failed to download transcription: HTTP ${transRes.status}`,
ExitCode.GENERAL,
);
}
const transData = (await transRes.json()) as Record<string, unknown>;
allTransData.push(transData);
// Extract and output text from transcripts[]
const transcripts = transData.transcripts as
| Array<{
text?: string;
sentences?: Array<{ text: string; speaker_id?: number }>;
}>
| undefined;
if (transcripts && transcripts.length > 0) {
for (const transcript of transcripts) {
if (transcript.sentences && transcript.sentences.length > 0) {
for (const sentence of transcript.sentences) {
const speakerTag =
sentence.speaker_id !== undefined ? ` [Speaker ${sentence.speaker_id}]` : "";
process.stdout.write(`${sentence.text}${speakerTag}\n`);
}
} else if (transcript.text) {
process.stdout.write(transcript.text + "\n");
}
}
} else {
emitBare(JSON.stringify(transData));
}
if (isMulti) process.stdout.write("\n");
}
// Save to --out file
if (flags.out) {
const outPath = flags.out as string;
const outData = allTransData.length === 1 ? allTransData[0] : allTransData;
writeFileSync(outPath, JSON.stringify(outData, null, 2) + "\n");
if (!config.quiet) {
process.stderr.write(`Full result saved to: ${outPath}\n`);
}
}
}
@@ -0,0 +1,474 @@
import { readFileSync, createWriteStream } from "fs";
import {
BailianError,
defineCommand,
ExitCode,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeTTSRequest,
type DashScopeTTSResponse,
type DashScopeTTSStreamChunk,
stripUndefined,
requestJson,
type OutputFormat,
speechSynthesizeEndpoint,
parseSSE,
isInteractive,
resolveOutputDir,
request,
DOCS_HOSTS,
} from "bailian-cli-core";
const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`;
import { downloadFile } from "../../utils/download.ts";
import { runConcurrent, downloadParallel, getConcurrency } from "../../utils/concurrent.ts";
import { promptText, promptSelect, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
interface VoiceEntry {
voice: string;
name: string;
desc: string;
lang: string;
}
// cosyvoice-v3-flash system voices
const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [
// 社交陪伴
{ voice: "longanyang", name: "龙安洋", desc: "阳光大男孩", lang: "中文/英文" },
{ voice: "longanhuan", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" },
{ voice: "longantai_v3", name: "龙安台", desc: "嗲甜台湾女", lang: "中文/英文" },
{ voice: "longhua_v3", name: "龙华", desc: "元气甜美女", lang: "中文/英文" },
{ voice: "longcheng_v3", name: "龙橙", desc: "智慧青年男", lang: "中文/英文" },
{ voice: "longze_v3", name: "龙泽", desc: "温暖元气男", lang: "中文/英文" },
{ voice: "longzhe_v3", name: "龙哲", desc: "呆板大暖男", lang: "中文/英文" },
{ voice: "longyan_v3", name: "龙颜", desc: "温暖春风女", lang: "中文/英文" },
{ voice: "longxing_v3", name: "龙星", desc: "温婉邻家女", lang: "中文/英文" },
{ voice: "longtian_v3", name: "龙天", desc: "磁性理智男", lang: "中文/英文" },
{ voice: "longwan_v3", name: "龙婉", desc: "细腻柔声女", lang: "中文/英文" },
{ voice: "longqiang_v3", name: "龙嫱", desc: "浪漫风情女", lang: "中文/英文" },
{ voice: "longfeifei_v3", name: "龙菲菲", desc: "甜美娇气女", lang: "中文/英文" },
{ voice: "longhao_v3", name: "龙浩", desc: "多情忧郁男", lang: "中文/英文" },
{ voice: "longanrou_v3", name: "龙安柔", desc: "温柔娴静女", lang: "中文/英文" },
// 语音助手
{ voice: "longxiaochun_v3", name: "龙小淳", desc: "知性积极女", lang: "中文/英文" },
{ voice: "longxiaoxia_v3", name: "龙小夏", desc: "沉稳权威女", lang: "中文/英文" },
{ voice: "longyumi_v3", name: "YUMI", desc: "正经青年女", lang: "中文/英文" },
{ voice: "longanyun_v3", name: "龙安昀", desc: "居家暖男", lang: "中文/英文" },
{ voice: "longanwen_v3", name: "龙安温", desc: "优雅知性女", lang: "中文/英文" },
{ voice: "longanli_v3", name: "龙安莉", desc: "利落从容女", lang: "中文/英文" },
{ voice: "longanlang_v3", name: "龙安朗", desc: "清爽利落男", lang: "中文/英文" },
{ voice: "longyingmu_v3", name: "龙应沐", desc: "优雅知性女", lang: "中文/英文" },
// 客服
{ voice: "longyingxun_v3", name: "龙应询", desc: "年轻青涩男", lang: "中文/英文" },
{ voice: "longyingjing_v3", name: "龙应静", desc: "低调冷静女", lang: "中文/英文" },
{ voice: "longyingling_v3", name: "龙应聆", desc: "温和共情女", lang: "中文/英文" },
{ voice: "longyingtao_v3", name: "龙应桃", desc: "温柔淡定女", lang: "中文/英文" },
// 电话销售
{ voice: "longyingxiao_v3", name: "龙应笑", desc: "清甜推销女", lang: "中文/英文" },
// 诗词朗诵
{ voice: "longfei_v3", name: "龙飞", desc: "热血磁性男", lang: "中文/英文" },
// 童声
{ voice: "longhuhu_v3", name: "龙呼呼", desc: "天真烂漫女童", lang: "中文/英文" },
{ voice: "longpaopao_v3", name: "龙泡泡", desc: "飞天泡泡音", lang: "中文/英文" },
{ voice: "longjielidou_v3", name: "龙杰力豆", desc: "阳光顽皮男", lang: "中文/英文" },
{ voice: "longxian_v3", name: "龙仙", desc: "豪放可爱女", lang: "中文/英文" },
{ voice: "longling_v3", name: "龙铃", desc: "稚气呆板女", lang: "中文/英文" },
{ voice: "longshanshan_v3", name: "龙闪闪", desc: "戏剧化童声", lang: "中文/英文" },
{ voice: "longniuniu_v3", name: "龙牛牛", desc: "阳光男童声", lang: "中文/英文" },
// 方言
{ voice: "longjiaxin_v3", name: "龙嘉欣", desc: "优雅粤语女", lang: "粤语/英文" },
{ voice: "longjiayi_v3", name: "龙嘉怡", desc: "知性粤语女", lang: "粤语/英文" },
{ voice: "longanyue_v3", name: "龙安粤", desc: "欢脱粤语男", lang: "粤语/英文" },
{ voice: "longlaotie_v3", name: "龙老铁", desc: "东北直率男", lang: "东北话/英文" },
{ voice: "longshange_v3", name: "龙陕哥", desc: "原味陕北男", lang: "陕西话/英文" },
{ voice: "longanmin_v3", name: "龙安闽", desc: "清纯萝莉女", lang: "闽南话/英文" },
// 出海营销(仅北京地域)
{ voice: "loongabby_v3", name: "loongabby", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongandy_v3", name: "loongandy", desc: "美式英文男", lang: "美式英语" },
{ voice: "loongannie_v3", name: "loongannie", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongava_v3", name: "loongava", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongbeth_v3", name: "loongbeth", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongbetty_v3", name: "loongbetty", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongcally_v3", name: "loongcally", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongcindy_v3", name: "loongcindy", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongdavid_v3", name: "loongdavid", desc: "美式英文男", lang: "美式英语" },
{ voice: "loongdonna_v3", name: "loongdonna", desc: "美式英文女", lang: "美式英语" },
{ voice: "loongemily_v3", name: "loongemily", desc: "英式英文女", lang: "英式英语" },
{ voice: "loongeric_v3", name: "loongeric", desc: "英式英文男", lang: "英式英语" },
{ voice: "loongluna_v3", name: "loongluna", desc: "英式英文女", lang: "英式英语" },
{ voice: "loongluca_v3", name: "loongluca", desc: "英式英文男", lang: "英式英语" },
{ voice: "loongriko_v3", name: "Riko", desc: "二次元霓虹女", lang: "日语" },
{ voice: "loongtomoka_v3", name: "loongtomoka", desc: "日语女", lang: "日语" },
{ voice: "loongtomoya_v3", name: "loongtomoya", desc: "日语男", lang: "日语" },
{ voice: "loongyuuna_v3", name: "Yuuna", desc: "日语女", lang: "日语" },
{ voice: "loongyuuma_v3", name: "Yuuma", desc: "日语男", lang: "日语" },
{ voice: "loongkyong_v3", name: "loongkyong", desc: "韩语女", lang: "韩语" },
{ voice: "loongjihun_v3", name: "Jihun", desc: "韩语男", lang: "韩语" },
{ voice: "loongindah_v3", name: "loongindah", desc: "印尼女", lang: "印尼语" },
];
const MODEL_VOICES: Record<string, VoiceEntry[]> = {
"cosyvoice-v3-flash": COSYVOICE_V3_FLASH_VOICES,
"cosyvoice-v3-plus": COSYVOICE_V3_FLASH_VOICES,
"cosyvoice-v3.5-flash": [],
"cosyvoice-v3.5-plus": [],
"cosyvoice-v2": [],
};
function printVoiceList(model: string): void {
const voices = MODEL_VOICES[model];
if (!voices) {
process.stdout.write(`No built-in voice list available for model: ${model}\n`);
return;
}
if (voices.length === 0) {
process.stdout.write(`Model ${model} has no system voices.\n`);
process.stdout.write("Use clone or design voices created via the CosyVoice API.\n");
process.stdout.write(`See: ${COSYVOICE_CLONE_DESIGN_DOC}\n`);
return;
}
const col = (s: string, w: number) => s.padEnd(w);
process.stdout.write(`\nSystem voices for ${model}:\n`);
process.stdout.write(
`${col("VOICE ID", 26)} ${col("NAME", 10)} ${col("DESCRIPTION", 16)} LANGUAGE\n`,
);
process.stdout.write(`${"-".repeat(26)} ${"-".repeat(10)} ${"-".repeat(16)} ${"-".repeat(12)}\n`);
for (const v of voices) {
process.stdout.write(`${col(v.voice, 26)} ${col(v.name, 10)} ${col(v.desc, 16)} ${v.lang}\n`);
}
process.stdout.write(`\nTotal: ${voices.length} voices\n`);
}
export default defineCommand({
name: "speech synthesize",
description: "Synthesize speech from text (CosyVoice TTS)",
apiDocs: "/developer-reference/cosyvoice",
usage: "bl speech synthesize --text <text> [flags]",
options: [
{ flag: "--text <text>", description: "Text to synthesize into speech", required: true },
{ flag: "--text-file <path>", description: "Read text from a file instead of --text" },
{
flag: "--model <model>",
description:
"Model ID (default: cosyvoice-v3-flash). System voices available for cosyvoice-v3-flash",
},
{
flag: "--voice <voice>",
description:
"Voice ID. Use --list-voices to see system voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID",
},
{
flag: "--list-voices",
description: "List available system voices for the selected model and exit",
},
{ flag: "--format <format>", description: "Audio format: mp3, pcm, wav, opus (default: mp3)" },
{ flag: "--sample-rate <rate>", description: "Audio sample rate in Hz (e.g. 24000)" },
{ flag: "--volume <volume>", description: "Volume 0-100 (default: 50)" },
{ flag: "--rate <rate>", description: "Speech rate 0.5-2.0 (default: 1.0)" },
{ flag: "--pitch <pitch>", description: "Pitch multiplier 0.5-2.0 (default: 1.0)" },
{ flag: "--seed <seed>", description: "Random seed 0-65535 for reproducible synthesis" },
{ flag: "--language <lang>", description: "Language hint (e.g. zh, en, ja, ko, fr, de)" },
{
flag: "--instruction <text>",
description: 'Natural language instruction to control speech style (e.g. "请用温柔的语调"',
},
{ flag: "--enable-ssml", description: "Enable SSML markup parsing in input text" },
{
flag: "--out <path>",
description: "Save audio to file (default: auto-generate in temp dir)",
},
{ flag: "--stream", description: "Stream raw PCM audio to stdout (pipe to player)" },
],
examples: [
"bl speech synthesize --list-voices --model cosyvoice-v3-flash",
'bl speech synthesize --text "你好,我是千问" --voice <voice_id>',
'bl speech synthesize --text "Hello world" --voice <voice_id> --language en',
"bl speech synthesize --text-file script.txt --out speech.wav --voice <voice_id>",
'bl speech synthesize --text "今天天气真好" --voice <voice_id> --instruction "请用温柔的语调说话"',
'bl speech synthesize --text "Hello" --voice <voice_id> --format wav --sample-rate 24000',
"# Stream to audio player (macOS)",
'bl speech synthesize --text "你好" --voice <voice_id> --stream | afplay -',
"# Pipe to ffplay",
'bl speech synthesize --text "Hello" --voice <voice_id> --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -',
],
async run(config: Config, flags: GlobalFlags) {
const model = (flags.model as string) || config.defaultSpeechModel || "cosyvoice-v3-flash";
// --list-voices: print voice list for the model and exit
if (flags.listVoices) {
printVoiceList(model);
return;
}
let text = flags.text as string | undefined;
// --text-file takes precedence if provided and --text is empty
if (!text && flags.textFile) {
const filePath = flags.textFile as string;
try {
text = readFileSync(filePath, "utf-8").trim();
} catch {
throw new BailianError(`Cannot read text file: ${filePath}`, ExitCode.USAGE);
}
}
if (!text) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "Enter text to synthesize:" });
if (!hint) {
process.stderr.write("Speech synthesis cancelled.\n");
process.exit(1);
}
text = hint;
} else {
failIfMissing("text", "bl speech synthesize --text <text>");
}
}
let voice = (flags.voice as string) || undefined;
// In interactive mode, prompt the user to select / enter a voice
if (!voice) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const modelVoices = MODEL_VOICES[model];
if (modelVoices && modelVoices.length > 0) {
const DEFAULT_VOICE = modelVoices[0]!.voice;
const choices = modelVoices.map((v) => ({
value: v.voice,
label: `${v.name} (${v.voice})`,
hint: `${v.desc} · ${v.lang}`,
}));
const selected = await promptSelect({
message: `Select a voice (default: ${DEFAULT_VOICE}):`,
choices,
defaultValue: DEFAULT_VOICE,
});
if (!selected) {
process.stderr.write("Speech synthesis cancelled.\n");
process.exit(1);
}
voice = selected;
} else {
// No built-in list (v3.5 / v2): prompt for clone/design voice ID
const entered = await promptText({ message: "Enter voice ID (clone/design voice):" });
if (!entered) {
process.stderr.write("Speech synthesis cancelled.\n");
process.exit(1);
}
voice = entered;
}
} else {
// Non-interactive mode: keep original error
const modelVoices = MODEL_VOICES[model];
if (modelVoices && modelVoices.length > 0) {
throw new BailianError(
`--voice is required.\nRun the following to see available voices:\n bl speech synthesize --list-voices --model ${model}`,
ExitCode.USAGE,
);
} else {
throw new BailianError(
`--voice is required. Model ${model} has no built-in system voices.\nCreate a clone or design voice first, then pass its ID via --voice <voice_id>.\nSee: ${COSYVOICE_CLONE_DESIGN_DOC}`,
ExitCode.USAGE,
);
}
}
}
const language = (flags.language as string) || undefined;
const instruction = (flags.instruction as string) || undefined;
const audioFormat = (flags.format as "mp3" | "pcm" | "wav" | "opus") || undefined;
const sampleRate = flags.sampleRate !== undefined ? Number(flags.sampleRate) : undefined;
const volume = flags.volume !== undefined ? Number(flags.volume) : undefined;
const rate = flags.rate !== undefined ? Number(flags.rate) : undefined;
const pitch = flags.pitch !== undefined ? Number(flags.pitch) : undefined;
const seed = flags.seed !== undefined ? Number(flags.seed) : undefined;
const enableSsml = flags.enableSsml === true ? true : undefined;
const useStream = flags.stream === true;
const format = detectOutputFormat(config.output);
const body: DashScopeTTSRequest = {
model,
input: {
text: text!,
voice,
format: audioFormat,
sample_rate: sampleRate,
volume,
rate,
pitch,
seed,
language_hints: language ? [language] : undefined,
instruction,
enable_ssml: enableSsml,
},
};
// Remove undefined fields from input
stripUndefined(body.input as Record<string, unknown>);
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
if (!config.quiet) {
process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`);
}
const url = speechSynthesizeEndpoint(config.baseUrl);
if (useStream) {
await handleStreamMode(config, url, body, flags, format);
} else {
await handleNonStreamMode(config, url, body, flags, format);
}
},
});
async function handleNonStreamMode(
config: Config,
url: string,
body: DashScopeTTSRequest,
flags: GlobalFlags,
format: OutputFormat,
): Promise<void> {
const concurrent = getConcurrency(flags);
const results = await runConcurrent(concurrent, config, () =>
requestJson<DashScopeTTSResponse>(config, { url, method: "POST", body }),
);
const audioUrls = results.map((r) => r.output?.audio?.url).filter(Boolean) as string[];
if (audioUrls.length === 0) {
throw new BailianError("API returned no audio URL.", ExitCode.GENERAL);
}
// Determine output paths
const path = await import("path");
const destDir = resolveOutputDir(config, { subDir: "speech" });
const items = audioUrls.map((audioUrl, i) => {
let destPath = flags.out as string | undefined;
if (destPath && audioUrls.length === 1) {
// Single explicit output path
} else {
const timestamp = Date.now();
const suffix = audioUrls.length > 1 ? `_${String(i + 1).padStart(3, "0")}` : "";
const ext = body.input.format ?? "mp3";
destPath = path.join(destDir, `tts_${timestamp}${suffix}.${ext}`);
}
return { url: audioUrl, destPath: destPath! };
});
const saved = await downloadParallel(items, downloadFile, { quiet: config.quiet });
if (config.quiet) {
emitBare(saved.join("\n"));
} else if (saved.length === 1) {
const expiresAt = results[0]!.output?.audio?.expires_at;
emitResult(
{
saved: saved[0],
audio_url: audioUrls[0],
model: body.model,
voice: body.input.voice,
...(expiresAt ? { url_expires_at: expiresAt } : {}),
},
format,
);
} else {
emitResult(
{
saved,
audio_urls: audioUrls,
total: saved.length,
model: body.model,
voice: body.input.voice,
},
format,
);
}
}
async function handleStreamMode(
config: Config,
url: string,
body: DashScopeTTSRequest,
flags: GlobalFlags,
format: OutputFormat,
): Promise<void> {
const res = await request(config, {
url,
method: "POST",
body,
stream: true,
headers: {
Accept: "text/event-stream",
"X-DashScope-SSE": "enable",
},
});
const outPath = flags.out as string | undefined;
const writer = outPath ? createWriteStream(outPath) : null;
let lastAudioUrl: string | undefined;
try {
for await (const event of parseSSE(res)) {
if (!event.data || event.data === "[DONE]") continue;
let chunk: DashScopeTTSStreamChunk;
try {
chunk = JSON.parse(event.data) as DashScopeTTSStreamChunk;
} catch {
continue;
}
const audioData = chunk.output?.audio?.data;
if (audioData) {
const buffer = Buffer.from(audioData, "base64");
if (writer) {
const ok = writer.write(buffer);
if (!ok) await new Promise<void>((resolve) => writer.once("drain", () => resolve()));
} else {
process.stdout.write(buffer);
}
}
if (chunk.output?.finish_reason === "stop") {
lastAudioUrl = chunk.output?.audio?.url;
if (lastAudioUrl && !config.quiet) {
process.stderr.write(`\nFull audio URL: ${lastAudioUrl}\n`);
}
break;
}
}
} finally {
if (writer) {
await new Promise<void>((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
writer.end();
});
if (!config.quiet && outPath) {
process.stderr.write(`Saved: ${outPath}\n`);
}
}
}
// Emit structured result for agent consumption
if (outPath) {
emitResult(
{
saved: outPath,
...(lastAudioUrl ? { audio_url: lastAudioUrl } : {}),
model: body.model,
voice: body.input.voice,
},
format,
);
}
}
+258
View File
@@ -0,0 +1,258 @@
import {
defineCommand,
request,
requestJson,
chatEndpoint,
parseSSE,
detectOutputFormat,
type Config,
type GlobalFlags,
type ChatMessage,
type ChatRequest,
type ChatResponse,
type StreamChunk,
isInteractive,
} from "bailian-cli-core";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { readFileSync } from "fs";
interface ParsedMessages {
system?: string;
messages: ChatMessage[];
}
function parseMessages(flags: GlobalFlags): ParsedMessages {
const messages: ChatMessage[] = [];
let system: string | undefined;
if (flags.system) {
system = flags.system as string;
}
if (flags.messagesFile) {
const filePath = flags.messagesFile as string;
const raw =
filePath === "-" ? readFileSync("/dev/stdin", "utf-8") : readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
for (const m of parsed) {
if (m.role === "system") {
system = typeof m.content === "string" ? m.content : "";
} else {
messages.push(m as ChatMessage);
}
}
}
if (flags.message) {
const validRoles = new Set(["system", "user", "assistant"]);
const msgs = flags.message as string[];
for (const m of msgs) {
const colonIdx = m.indexOf(":");
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
if (validRoles.has(maybeRole)) {
const content = m.slice(colonIdx + 1);
if (maybeRole === "system") {
system = content;
} else {
messages.push({ role: maybeRole as "user" | "assistant", content });
}
} else {
messages.push({ role: "user", content: m });
}
}
}
return { system, messages };
}
export default defineCommand({
name: "text chat",
description: "Send a chat completion (OpenAI compatible, DashScope)",
apiDocs: "/compatibility-of-openai-with-dashscope",
usage: "bl text chat --message <text> [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: qwen3.7-max)" },
{
flag: "--message <text>",
description: "Message text (repeatable, prefix role: to set role)",
required: true,
type: "array",
},
{
flag: "--messages-file <path>",
description: "JSON file with messages array (use - for stdin)",
},
{ flag: "--system <text>", description: "System prompt" },
{
flag: "--max-tokens <n>",
description: "Maximum tokens to generate (default: 4096)",
type: "number",
},
{ flag: "--temperature <n>", description: "Sampling temperature (0.0, 2.0]", type: "number" },
{ flag: "--top-p <n>", description: "Nucleus sampling threshold", type: "number" },
{ flag: "--stream", description: "Stream response tokens (default: on in TTY)" },
{
flag: "--tool <json-or-path>",
description: "Tool definition as JSON or file path (repeatable)",
type: "array",
},
{
flag: "--enable-thinking",
description: "Enable thinking/reasoning mode (for qwen3/qwq models)",
},
{
flag: "--thinking-budget <n>",
description: "Max tokens for thinking (default: 4096)",
type: "number",
},
],
examples: [
'bl text chat --message "What is Qwen?"',
'bl text chat --model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"',
'bl text chat --message "Hello" --message "assistant:Hi!" --message "How are you?"',
"cat conversation.json | bl text chat --messages-file - --stream",
'bl text chat --message "Hello" --output json',
'bl text chat --model qwq-plus --message "Solve 1+1" --enable-thinking',
],
async run(config: Config, flags: GlobalFlags) {
const { system, messages: parsedMessages } = parseMessages(flags);
let messages = parsedMessages;
if (messages.length === 0) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: "Enter your message:",
});
if (!hint) {
process.stderr.write("Chat cancelled.\n");
process.exit(1);
}
messages = [{ role: "user", content: hint }];
} else {
failIfMissing("message", "bl text chat --message <text>");
}
}
const model = (flags.model as string) || config.defaultTextModel || "qwen3.7-max";
const shouldStream =
flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
const format = detectOutputFormat(config.output);
// Build messages array with system prompt
const allMessages: ChatMessage[] = [];
if (system) {
allMessages.push({ role: "system", content: system });
}
allMessages.push(...messages);
const body: ChatRequest = {
model,
messages: allMessages,
max_tokens: (flags.maxTokens as number) ?? 4096,
stream: shouldStream,
};
if (flags.temperature !== undefined) body.temperature = flags.temperature as number;
if (flags.topP !== undefined) body.top_p = flags.topP as number;
if (flags.enableThinking) {
body.enable_thinking = true;
if (flags.thinkingBudget !== undefined) {
body.thinking_budget = flags.thinkingBudget as number;
}
}
if (flags.tool) {
const tools = (flags.tool as string[]).map((t) => {
try {
return JSON.parse(t);
} catch {
const raw = readFileSync(t, "utf-8");
return JSON.parse(raw);
}
});
body.tools = tools;
}
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
const url = chatEndpoint(config.baseUrl);
if (shouldStream) {
const res = await request(config, {
url,
method: "POST",
body,
stream: true,
});
let textContent = "";
let inThinking = false;
const writesStreamingStdout = format === "text";
const dim = config.noColor ? "" : "\x1b[2m";
const reset = config.noColor ? "" : "\x1b[0m";
const isTTY = process.stdout.isTTY;
const statusOut =
format === "json" ? process.stderr : isTTY ? process.stdout : process.stderr;
const resultOut = process.stdout;
for await (const event of parseSSE(res)) {
if (event.data === "[DONE]") break;
try {
const parsed = JSON.parse(event.data) as StreamChunk;
for (const choice of parsed.choices) {
const delta = choice.delta;
// Handle thinking/reasoning content
if (delta.reasoning_content) {
if (writesStreamingStdout && !inThinking) {
inThinking = true;
statusOut.write(`${dim}Thinking:\n`);
}
if (writesStreamingStdout) statusOut.write(delta.reasoning_content);
}
// Handle regular content
if (delta.content) {
if (writesStreamingStdout && inThinking) {
statusOut.write(`${reset}\n\nResponse:\n`);
inThinking = false;
}
textContent += delta.content;
if (writesStreamingStdout) resultOut.write(delta.content);
}
}
} catch {
// Skip unparseable chunks
}
}
if (inThinking) statusOut.write(reset);
if (format === "json") {
emitResult({ content: textContent }, format);
} else {
resultOut.write("\n");
}
} else {
const response = await requestJson<ChatResponse>(config, {
url,
method: "POST",
body,
});
const text = response.choices?.[0]?.message?.content ?? "";
if (config.quiet || format === "text") {
emitBare(text);
} else {
emitResult(response, format);
}
}
},
});
+70
View File
@@ -0,0 +1,70 @@
import { execSync } from "child_process";
import { writeFileSync } from "fs";
import { join } from "path";
import { defineCommand, getConfigDir } from "bailian-cli-core";
import { CLI_VERSION } from "../version.ts";
import { NPM_PACKAGE, fetchLatestVersion } from "../utils/update-checker.ts";
/** Build the install command */
function detectInstallCommand(): { cmd: string; label: string } {
return { cmd: `npm install -g ${NPM_PACKAGE}@latest`, label: "npm" };
}
export default defineCommand({
name: "update",
description: "Update bl to the latest version",
usage: "bl update",
examples: ["bl update"],
async run() {
const isTTY = process.stderr.isTTY;
const green = isTTY ? "\x1b[32m" : "";
const yellow = isTTY ? "\x1b[33m" : "";
const reset = isTTY ? "\x1b[0m" : "";
process.stderr.write(`Current version: ${yellow}${CLI_VERSION}${reset}\n`);
// Check latest version first
process.stderr.write("Checking for updates...\n");
const latest = await fetchLatestVersion(5000);
if (latest && latest === CLI_VERSION) {
process.stderr.write(`${green}\u2713 Already up to date (${CLI_VERSION}).${reset}\n`);
return;
}
if (latest) {
process.stderr.write(`Latest version: ${green}${latest}${reset}\n\n`);
}
const { cmd, label } = detectInstallCommand();
process.stderr.write(`Updating ${NPM_PACKAGE} via ${label}...\n\n`);
try {
execSync(cmd, { stdio: "inherit" });
// Verify the installed version after update
try {
const rawVer = execSync("bl --version 2>/dev/null", { encoding: "utf-8" }).trim();
// bl --version outputs "bl X.Y.Z" — extract just the version number
const newVer = rawVer.replace(/^bl\s+/, "");
process.stderr.write(
`\n${green}\u2713 Update complete: ${CLI_VERSION} \u2192 ${newVer}${reset}\n`,
);
// Update the cached state so the post-run notification doesn't fire
try {
const stateFile = join(getConfigDir(), "update-state.json");
writeFileSync(
stateFile,
JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer }),
);
} catch {
/* ignore */
}
} catch {
process.stderr.write(`\n${green}\u2713 Update complete.${reset}\n`);
}
} catch {
process.stderr.write("\nAutomatic update failed. Please run manually:\n");
process.stderr.write(` ${cmd}\n\n`);
}
},
});
+65
View File
@@ -0,0 +1,65 @@
import {
defineCommand,
callConsoleGateway,
resolveConsoleGatewayCredential,
detectOutputFormat,
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult } from "../../output/output.ts";
const FREE_TIER_API = "zeldaEasy.broadscope-bailian.freeTrial.queryFreeTierQuota";
export default defineCommand({
name: "usage free",
description: "Query free-tier quota for a model",
usage: "bl usage free --model <model> [flags]",
options: [
{
flag: "--model <model>",
description: "Model name to query (e.g. qwen3-max, qwen-turbo)",
required: true,
},
{
flag: "--region <region>",
description: "API region (default: cn-beijing)",
},
],
examples: [
"bl usage free --model qwen3-max",
"bl usage free --model qwen-turbo --output json",
"bl usage free --model qwen3-max --region cn-beijing",
],
async run(config: Config, flags: GlobalFlags) {
const model = flags.model as string;
if (!model) failIfMissing("model", "bl usage free --model <model>");
const region = (flags.region as string) || "cn-beijing";
const format = detectOutputFormat(config.output);
const credential = await resolveConsoleGatewayCredential(config);
const data = {
queryFreeTierQuotaRequest: {
models: [model],
},
};
if (config.dryRun) {
emitResult(
{ api: FREE_TIER_API, data, region, token: credential.token.slice(0, 8) + "..." },
format,
);
return;
}
const result = await callConsoleGateway(config, credential.token, {
api: FREE_TIER_API,
data,
region,
});
emitResult(result, format);
},
});
@@ -0,0 +1,70 @@
import {
defineCommand,
requestJson,
taskEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeTaskResponse,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { downloadFile, formatBytes } from "../../utils/download.ts";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "video download",
description: "Download a completed video by task ID",
usage: "bl video download --task-id <id> --out <path>",
options: [
{ flag: "--task-id <id>", description: "Task ID to download from" },
{ flag: "--out <path>", description: "Output file path" },
],
examples: [
"bl video download --task-id 3b256896-xxxx --out video.mp4",
"bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet",
],
async run(config: Config, flags: GlobalFlags) {
const taskId = flags.taskId as string | undefined;
if (!taskId) failIfMissing("task-id", "bl video download --task-id <id> --out <path>");
const outPath = flags.out as string | undefined;
if (!outPath) failIfMissing("out", "bl video download --task-id <id> --out video.mp4");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ task_id: taskId, action: "download", out: outPath }, format);
return;
}
// Get task info to find video URL
const url = taskEndpoint(config.baseUrl, taskId);
const taskInfo = await requestJson<DashScopeTaskResponse>(config, { url });
if (taskInfo.output.task_status !== "SUCCEEDED") {
throw new BailianError(
`Task is not complete (status: ${taskInfo.output.task_status}).`,
ExitCode.GENERAL,
"Wait for the task to complete before downloading.",
);
}
const downloadUrl =
taskInfo.output.video_url || (taskInfo.output.results && taskInfo.output.results[0]?.url);
if (!downloadUrl) {
throw new BailianError("No download URL available for this task.", ExitCode.GENERAL);
}
const { size } = await downloadFile(downloadUrl, outPath, { quiet: config.quiet });
if (config.quiet) {
emitBare(outPath);
return;
}
emitResult({ saved: outPath, size: formatBytes(size) }, format);
},
});
+235
View File
@@ -0,0 +1,235 @@
import {
defineCommand,
requestJson,
videoGenerateEndpoint,
taskEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeVideoEditRequest,
type DashScopeAsyncResponse,
type DashScopeTaskResponse,
isInteractive,
resolveOutputDir,
resolveFileUrl,
resolveCredential,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { poll } from "../../utils/polling.ts";
import { downloadFile, formatBytes } from "../../utils/download.ts";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "video edit",
description:
"Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.)",
apiDocs: "/best-practice/wanx/video-edit",
usage: "bl video edit --video <url> --prompt <text> [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.0-video-edit)" },
{
flag: "--video <url>",
description: "Input video URL or local file (mp4/mov, 2-10s)",
required: true,
},
{ flag: "--prompt <text>", description: 'Edit instruction (e.g. "将画面转换为黏土风格")' },
{ flag: "--ref-image <url>", description: "Reference image URL (up to 4, comma-separated)" },
{
flag: "--negative-prompt <text>",
description: "Negative prompt to exclude unwanted content",
},
{ flag: "--resolution <res>", description: "Resolution: 720P or 1080P (default: 1080P)" },
{ flag: "--ratio <ratio>", description: "Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4)" },
{
flag: "--duration <seconds>",
description: "Output video duration in seconds (2-10)",
type: "number",
},
{
flag: "--audio-setting <mode>",
description: "Audio: auto (default) or origin (keep original)",
},
{ flag: "--prompt-extend", description: "Enable prompt intelligent rewriting (default: true)" },
{ flag: "--no-prompt-extend", description: "Disable prompt intelligent rewriting" },
{ flag: "--watermark", description: 'Add "AI生成" watermark' },
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{ flag: "--download <path>", description: "Save video to file on completion" },
{ flag: "--no-wait", description: "Return task ID immediately without waiting" },
{
flag: "--async",
description: "Return task ID immediately (agent/CI mode, same as --no-wait)",
},
{
flag: "--poll-interval <seconds>",
description: "Polling interval when waiting (default: 15)",
type: "number",
},
],
examples: [
'bl video edit --video https://example.com/input.mp4 --prompt "将整个画面转换为黏土风格"',
'bl video edit --video https://example.com/input.mp4 --prompt "替换衣服为图片中的款式" --ref-image https://example.com/clothes.png',
'bl video edit --video https://example.com/input.mp4 --prompt "Convert to anime style" --resolution 720P --download output.mp4',
],
async run(config: Config, flags: GlobalFlags) {
// --- Validate video URL ---
let videoUrl = flags.video as string | undefined;
if (!videoUrl) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "Enter the video URL to edit:" });
if (!hint) {
process.stderr.write("Video editing cancelled.\n");
process.exit(1);
}
videoUrl = hint;
} else {
failIfMissing("video", "bl video edit --video <url> --prompt <text>");
}
}
// --- Prompt ---
let prompt = flags.prompt as string | undefined;
if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "Enter your edit instruction:" });
if (!hint) {
process.stderr.write("Video editing cancelled.\n");
process.exit(1);
}
prompt = hint;
}
// prompt is optional for video edit per API spec
}
const model = (flags.model as string) || "happyhorse-1.0-video-edit";
const format = detectOutputFormat(config.output);
// Auto-upload local files
const credential = await resolveCredential(config);
const resolvedVideoUrl = await resolveFileUrl(videoUrl!, credential.token, model);
// --- Build media array ---
const media: DashScopeVideoEditRequest["input"]["media"] = [
{ type: "video", url: resolvedVideoUrl },
];
// Support comma-separated reference images
const refImageArg = flags.refImage as string | undefined;
if (refImageArg) {
const images = refImageArg
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const imgUrl of images) {
const resolved = await resolveFileUrl(imgUrl, credential.token, model);
media.push({ type: "reference_image", url: resolved });
}
}
// --- Build request body ---
const promptExtend =
flags.noPromptExtend === true ? false : flags.promptExtend === true ? true : undefined;
const body: DashScopeVideoEditRequest = {
model,
input: {
prompt: prompt || undefined,
negative_prompt: (flags.negativePrompt as string) || undefined,
media,
},
parameters: {
resolution: (flags.resolution as string) || undefined,
ratio: (flags.ratio as string) || undefined,
duration: (flags.duration as number) || undefined,
audio_setting: (flags.audioSetting as "auto" | "origin") || undefined,
prompt_extend: promptExtend,
watermark: flags.watermark === true ? true : undefined,
seed: flags.seed as number | undefined,
},
};
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
// --- Submit async task ---
const url = videoGenerateEndpoint(config.baseUrl);
const response = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
});
const taskId = response.output.task_id;
if (!config.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) {
emitResult({ task_id: taskId }, format);
return;
}
// --- Poll until completion ---
// Video editing is compute-intensive; default timeout = 600s (10 min)
const pollInterval = (flags.pollInterval as number) ?? 15;
const pollUrl = taskEndpoint(config.baseUrl, taskId);
const editTimeout = Math.max(config.timeout, 600);
const result = await poll<DashScopeTaskResponse>(config, {
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: editTimeout,
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,
getErrorMessage: (d) => {
const o = (d as DashScopeTaskResponse).output;
return o.message || o.code || undefined;
},
});
const resultVideoUrl =
result.output.video_url || (result.output.results && result.output.results[0]?.url);
if (!resultVideoUrl) {
throw new BailianError("Task completed but no video URL returned.", ExitCode.GENERAL);
}
// --download: save to file
if (flags.download) {
const destPath = flags.download as string;
const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet });
if (config.quiet) {
emitBare(destPath);
} else {
emitResult(
{
task_id: taskId,
video_url: resultVideoUrl,
status: "SUCCEEDED",
saved: destPath,
size: formatBytes(size),
},
format,
);
}
return;
}
// Default: auto-download to output directory
const path = await import("path");
const destDir = resolveOutputDir(config, { subDir: "videos" });
const destPath = path.join(destDir, `${taskId}.mp4`);
await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet });
emitResult({ task_id: taskId, video_url: resultVideoUrl, saved: destPath }, format);
},
});
+245
View File
@@ -0,0 +1,245 @@
import {
defineCommand,
requestJson,
videoGenerateEndpoint,
taskEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeVideoRequest,
type DashScopeAsyncResponse,
type DashScopeTaskResponse,
isInteractive,
resolveOutputDir,
resolveFileUrl,
resolveCredential,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { poll } from "../../utils/polling.ts";
import { downloadFile, formatBytes } from "../../utils/download.ts";
import { runConcurrent, getConcurrency } from "../../utils/concurrent.ts";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
// Normalize shorthand resolution (720P, 1080P) to pixel format for video generation models
const RESOLUTION_SHORTCUTS: Record<string, string> = {
"720p": "1280*720",
"1080p": "1920*1080",
"480p": "832*480",
};
function normalizeResolution(res: string | undefined): string | undefined {
if (!res) return undefined;
return RESOLUTION_SHORTCUTS[res.toLowerCase()] || res;
}
export default defineCommand({
name: "video generate",
description:
"Generate a video from text or image (happyhorse-1.0-t2v / happyhorse-1.0-i2v / wan2.6-t2v)",
apiDocs: "/best-practice/wanx/text-to-video",
usage: "bl video generate --prompt <text> [--image <url>] [flags]",
options: [
{
flag: "--model <model>",
description: "Model ID (default: happyhorse-1.0-t2v, or happyhorse-1.0-i2v with --image)",
},
{ flag: "--prompt <text>", description: "Video description", required: true },
{ flag: "--image <url>", description: "Input image URL for image-to-video generation" },
{
flag: "--negative-prompt <text>",
description: "Negative prompt to exclude unwanted content",
},
{ flag: "--resolution <res>", description: "Resolution (e.g. 1280*720, 960*960)" },
{ flag: "--ratio <ratio>", description: "Aspect ratio (e.g. 16:9, 1:1)" },
{
flag: "--duration <seconds>",
description: "Video duration in seconds (default: 5)",
type: "number",
},
{ flag: "--prompt-extend", description: "Automatically extend prompt for better results" },
{ flag: "--watermark", description: "Add watermark to generated video" },
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{ flag: "--download <path>", description: "Save video to file on completion" },
{ flag: "--no-wait", description: "Return task ID immediately without waiting" },
{
flag: "--async",
description: "Return task ID immediately (agent/CI mode, same as --no-wait)",
},
{
flag: "--poll-interval <seconds>",
description: "Polling interval when waiting (default: 5)",
type: "number",
},
],
examples: [
'bl video generate --prompt "一个人在读书,静态镜头"',
'bl video generate --prompt "Ocean waves at sunset." --download sunset.mp4',
'bl video generate --image https://example.com/cat.png --prompt "让画面中的猫动起来"',
'bl video generate --prompt "Mountain landscape" --resolution 1280*720 --duration 5',
],
async run(config: Config, flags: GlobalFlags) {
let prompt = flags.prompt as string | undefined;
if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({ message: "Enter your video prompt:" });
if (!hint) {
process.stderr.write("Video generation cancelled.\n");
process.exit(1);
}
prompt = hint;
} else {
failIfMissing("prompt", "bl video generate --prompt <text>");
}
}
const model =
(flags.model as string) ||
config.defaultVideoModel ||
((flags.image as string) ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v");
const format = detectOutputFormat(config.output);
const imageUrl = flags.image as string | undefined;
// Auto-upload local image file for i2v
let resolvedImageUrl: string | undefined;
if (imageUrl) {
const credential = await resolveCredential(config);
resolvedImageUrl = await resolveFileUrl(imageUrl, credential.token, model);
}
const body: DashScopeVideoRequest = {
model,
input: {
prompt: prompt!,
negative_prompt: (flags.negativePrompt as string) || undefined,
// i2v models (happyhorse-1.0-i2v) require input.media with type 'first_frame'
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
},
parameters: {
resolution: normalizeResolution(flags.resolution as string) || undefined,
ratio: (flags.ratio as string) || undefined,
duration: (flags.duration as number) || undefined,
prompt_extend: flags.promptExtend === true ? true : undefined,
watermark: flags.watermark === true ? true : undefined,
seed: flags.seed as number | undefined,
},
};
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
// Submit async task(s) — supports --concurrent for parallel generation
const concurrent = getConcurrency(flags);
const url = videoGenerateEndpoint(config.baseUrl);
const responses = await runConcurrent(
concurrent,
config,
() =>
requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
}),
"tasks",
);
const taskIds = responses.map((r) => r.output.task_id);
if (!config.quiet) {
process.stderr.write(`[Model: ${model}]\n`);
}
// --no-wait or --async: return task ID(s) immediately
if (flags.noWait || config.async) {
emitResult(taskIds.length === 1 ? { task_id: taskIds[0] } : { task_ids: taskIds }, format);
return;
}
// Poll all tasks concurrently
const pollInterval = (flags.pollInterval as number) ?? 5;
const pollPromises = taskIds.map((taskId) => {
const pollUrl = taskEndpoint(config.baseUrl, taskId);
return poll<DashScopeTaskResponse>(config, {
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: config.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,
getErrorMessage: (d) => {
const o = (d as DashScopeTaskResponse).output;
return o.message || o.code || undefined;
},
});
});
const results = await Promise.all(pollPromises);
// Collect video URLs from all results
const videos: Array<{ taskId: string; videoUrl: string }> = [];
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
const videoUrl =
result.output.video_url || (result.output.results && result.output.results[0]?.url);
if (videoUrl) {
videos.push({ taskId: taskIds[i]!, videoUrl });
}
}
if (videos.length === 0) {
throw new BailianError("All tasks completed but no video URLs returned.", ExitCode.GENERAL);
}
// --download: save to file (first video only for explicit path)
if (flags.download) {
const destPath = flags.download as string;
const { size } = await downloadFile(videos[0]!.videoUrl, destPath, { quiet: config.quiet });
if (config.quiet) {
emitBare(destPath);
} else {
emitResult(
{
task_id: videos[0]!.taskId,
video_url: videos[0]!.videoUrl,
status: "SUCCEEDED",
saved: destPath,
size: formatBytes(size),
},
format,
);
}
return;
}
// Default: auto-download all to output directory
const destDir = resolveOutputDir(config, { subDir: "videos" });
// eslint-disable-next-line @typescript-eslint/unbound-method
const { join } = await import("path");
const saved: Array<{ task_id: string; video_url: string; saved: string }> = [];
await Promise.all(
videos.map(async ({ taskId, videoUrl }) => {
const destPath = join(destDir, `${taskId}.mp4`);
await downloadFile(videoUrl, destPath, { quiet: config.quiet });
saved.push({ task_id: taskId, video_url: videoUrl, saved: destPath });
}),
);
if (saved.length === 1) {
emitResult(saved[0]!, format);
} else {
emitResult({ videos: saved, total: saved.length }, format);
}
},
});
+265
View File
@@ -0,0 +1,265 @@
import {
defineCommand,
requestJson,
videoGenerateEndpoint,
taskEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeVideoRefRequest,
type DashScopeAsyncResponse,
type DashScopeTaskResponse,
isInteractive,
resolveOutputDir,
resolveFileUrl,
resolveCredential,
BailianError,
ExitCode,
} from "bailian-cli-core";
import { poll } from "../../utils/polling.ts";
import { downloadFile, formatBytes } from "../../utils/download.ts";
import { promptText, failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "video ref",
description:
"Reference-to-video generation (happyhorse-1.0-r2v / wan2.6-r2v): multi-subject, multi-shot with voice",
apiDocs: "/best-practice/wanx/video-reference",
usage: "bl video ref --prompt <text> --image <url>... [--ref-video <url>...] [flags]",
options: [
{ flag: "--model <model>", description: "Model ID (default: happyhorse-1.0-r2v)" },
{
flag: "--prompt <text>",
description: "Video description with reference markers (图1, 视频1, etc.)",
required: true,
},
{
flag: "--image <url>",
description: "Reference image URL or local file (repeatable for multiple subjects)",
type: "array",
},
{
flag: "--ref-video <url>",
description: "Reference video URL or local file (repeatable)",
type: "array",
},
{
flag: "--image-voice <url>",
description: "Voice URL for corresponding image (pairs by position)",
type: "array",
},
{
flag: "--video-voice <url>",
description: "Voice URL for corresponding ref-video (pairs by position)",
type: "array",
},
{ flag: "--resolution <res>", description: "Resolution: 720P or 1080P (default: 720P)" },
{ flag: "--ratio <ratio>", description: "Aspect ratio (16:9, 9:16, 1:1)" },
{
flag: "--duration <seconds>",
description: "Video duration in seconds (2-10, default: 5)",
type: "number",
},
{ flag: "--prompt-extend", description: "Enable prompt intelligent rewriting" },
{ flag: "--no-prompt-extend", description: "Disable prompt intelligent rewriting" },
{ flag: "--watermark", description: "Add watermark to generated video" },
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{ flag: "--download <path>", description: "Save video to file on completion" },
{ flag: "--no-wait", description: "Return task ID immediately without waiting" },
{
flag: "--async",
description: "Return task ID immediately (agent/CI mode, same as --no-wait)",
},
{
flag: "--poll-interval <seconds>",
description: "Polling interval when waiting (default: 15)",
type: "number",
},
],
examples: [
'bl video ref --prompt "图1在草地上奔跑" --image person.jpg',
'bl video ref --prompt "视频1在弹吉他图1走过来" --ref-video scene.mp4 --image person.jpg',
'bl video ref --prompt "图1说话" --image person.jpg --image-voice voice.mp3 --resolution 1080P',
'bl video ref --prompt "图1和图2在对话" --image a.jpg --image b.jpg --image-voice va.mp3 --image-voice vb.mp3',
],
async run(config: Config, flags: GlobalFlags) {
// --- Validate prompt ---
let prompt = flags.prompt as string | undefined;
if (!prompt) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: "Enter your video prompt (use 图1, 视频1 to reference inputs):",
});
if (!hint) {
process.stderr.write("Video generation cancelled.\n");
process.exit(1);
}
prompt = hint;
} else {
failIfMissing("prompt", "bl video ref --prompt <text> --image <url>");
}
}
const images = (flags.image as string[] | undefined) || [];
const refVideos = (flags.refVideo as string[] | undefined) || [];
if (images.length === 0 && refVideos.length === 0) {
throw new BailianError(
"At least one --image or --ref-video is required.",
ExitCode.USAGE,
'bl video ref --prompt "描述" --image person.jpg',
);
}
const imageVoices = (flags.imageVoice as string[] | undefined) || [];
const videoVoices = (flags.videoVoice as string[] | undefined) || [];
const model = (flags.model as string) || "happyhorse-1.0-r2v";
const format = detectOutputFormat(config.output);
// --- Resolve file URLs (auto-upload local files) ---
const credential = await resolveCredential(config);
const media: DashScopeVideoRefRequest["input"]["media"] = [];
// Add reference images
for (let i = 0; i < images.length; i++) {
const resolved = await resolveFileUrl(images[i]!, credential.token, model);
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
type: "reference_image",
url: resolved,
};
// Pair voice by position
if (imageVoices[i]) {
const resolvedVoice = await resolveFileUrl(imageVoices[i]!, credential.token, model);
entry.reference_voice = resolvedVoice;
}
media.push(entry);
}
// Add reference videos
for (let i = 0; i < refVideos.length; i++) {
const resolved = await resolveFileUrl(refVideos[i]!, credential.token, model);
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
type: "reference_video",
url: resolved,
};
// Pair voice by position
if (videoVoices[i]) {
const resolvedVoice = await resolveFileUrl(videoVoices[i]!, credential.token, model);
entry.reference_voice = resolvedVoice;
}
media.push(entry);
}
// --- Build request body ---
const promptExtend =
flags.noPromptExtend === true ? false : flags.promptExtend === true ? true : undefined;
const body: DashScopeVideoRefRequest = {
model,
input: {
prompt: prompt!,
media,
},
parameters: {
resolution: (flags.resolution as string) || undefined,
ratio: (flags.ratio as string) || undefined,
duration: (flags.duration as number) || undefined,
prompt_extend: promptExtend,
watermark: flags.watermark === true ? true : undefined,
seed: flags.seed as number | undefined,
},
};
if (config.dryRun) {
emitResult({ request: body }, format);
return;
}
// --- Submit async task ---
const url = videoGenerateEndpoint(config.baseUrl);
const response = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
});
const taskId = response.output.task_id;
if (!config.quiet) {
process.stderr.write(`[Model: ${model}]\n`);
process.stderr.write(
`Note: Reference-to-video typically takes 5-10 minutes. Please be patient.\n`,
);
}
// --no-wait or --async: return task ID immediately
if (flags.noWait || config.async) {
emitResult({ task_id: taskId }, format);
return;
}
// --- Poll until completion ---
const pollInterval = (flags.pollInterval as number) ?? 15;
const pollUrl = taskEndpoint(config.baseUrl, taskId);
const refTimeout = Math.max(config.timeout, 600);
const result = await poll<DashScopeTaskResponse>(config, {
url: pollUrl,
intervalSec: pollInterval,
timeoutSec: refTimeout,
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,
getErrorMessage: (d) => {
const o = (d as DashScopeTaskResponse).output;
return o.message || o.code || undefined;
},
});
const resultVideoUrl =
result.output.video_url || (result.output.results && result.output.results[0]?.url);
if (!resultVideoUrl) {
throw new BailianError("Task completed but no video URL returned.", ExitCode.GENERAL);
}
// --download: save to file
if (flags.download) {
const destPath = flags.download as string;
const { size } = await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet });
if (config.quiet) {
emitBare(destPath);
} else {
emitResult(
{
task_id: taskId,
video_url: resultVideoUrl,
status: "SUCCEEDED",
saved: destPath,
size: formatBytes(size),
},
format,
);
}
return;
}
// 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 destPath = join(destDir, `${taskId}.mp4`);
await downloadFile(resultVideoUrl, destPath, { quiet: config.quiet });
emitResult({ task_id: taskId, video_url: resultVideoUrl, saved: destPath }, format);
},
});
@@ -0,0 +1,53 @@
import {
defineCommand,
requestJson,
taskEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type DashScopeTaskResponse,
} from "bailian-cli-core";
import { failIfMissing } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
export default defineCommand({
name: "video task get",
description: "Query async task status",
usage: "bl video task get --task-id <id>",
options: [{ flag: "--task-id <id>", description: "Async task ID" }],
examples: [
"bl video task get --task-id 3b256896-3e70-xxxx-xxxx-xxxxxxxxxxxx",
"bl video task get --task-id 3b256896-3e70-xxxx --output json",
],
async run(config: Config, flags: GlobalFlags) {
const taskId = flags.taskId as string | undefined;
if (!taskId) failIfMissing("task-id", "bl video task get --task-id <id>");
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult({ task_id: taskId }, format);
return;
}
const url = taskEndpoint(config.baseUrl, taskId);
const response = await requestJson<DashScopeTaskResponse>(config, { url });
if (config.quiet) {
emitBare(response.output.task_status);
return;
}
emitResult(
{
task_id: response.output.task_id,
task_status: response.output.task_status,
video_url: response.output.video_url,
results: response.output.results,
submit_time: response.output.submit_time,
end_time: response.output.end_time,
},
format,
);
},
});
@@ -0,0 +1,196 @@
import {
defineCommand,
requestJson,
chatEndpoint,
detectOutputFormat,
type Config,
type GlobalFlags,
type ChatRequest,
type ChatResponse,
type ChatMessageContent,
isInteractive,
resolveFileUrl,
resolveCredential,
BailianError,
ExitCode,
isLocalFile,
} from "bailian-cli-core";
import { promptText } from "../../output/prompt.ts";
import { emitResult, emitBare } from "../../output/output.ts";
import { readFileSync, existsSync } from "fs";
import { extname } from "path";
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
function isVideoInput(input: string): boolean {
// Check by extension
const ext = extname(input).toLowerCase().split("?")[0]!;
if (VIDEO_EXTENSIONS.has(ext)) return true;
// URL heuristic: contains common video extensions
if (/\.(mp4|mov|avi|mkv|webm|flv|wmv)(\?|$)/i.test(input)) return true;
return false;
}
async function toImageUrl(image: string): Promise<string> {
if (image.startsWith("data:")) return image;
if (image.startsWith("http://") || image.startsWith("https://")) return image;
if (image.startsWith("oss://")) return image;
// Local file → data URI (for small files < 10MB, fallback)
if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE);
const ext = extname(image).toLowerCase();
const mime = IMAGE_MIME_TYPES[ext];
if (!mime)
throw new BailianError(
`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`,
ExitCode.USAGE,
);
const buf = readFileSync(image);
return `data:${mime};base64,${buf.toString("base64")}`;
}
export default defineCommand({
name: "vision describe",
description: "Describe an image or video using Qwen-VL",
usage: "bl vision describe --image <path-or-url> [--video <url>] [--prompt <text>]",
options: [
{ flag: "--image <path-or-url>", description: "Local image path or URL" },
{
flag: "--video <url>",
description: "Video file URL or local path (mp4/mov/avi/mkv/webm)",
type: "array",
},
{ flag: "--prompt <text>", description: "Question about the content (default: auto-detected)" },
{ flag: "--model <model>", description: "Vision model (default: qwen-vl-max)" },
],
examples: [
"bl vision describe --image photo.jpg",
'bl vision describe --image https://example.com/photo.jpg --prompt "这只狗是什么品种?"',
'bl vision describe --video https://example.com/video.mp4 --prompt "总结视频内容"',
"bl vision describe --video ./local-video.mp4",
'bl vision describe --image photo.png --prompt "Extract the text" --model qwen-vl-plus',
],
async run(config: Config, flags: GlobalFlags) {
let image = (flags.image ?? (flags._positional as string[] | undefined)?.[0]) as
| string
| undefined;
const videoInputs = (flags.video as string[] | undefined) ?? [];
const model = (flags.model as string) || "qwen-vl-max";
// Auto-detect: if --image was given a video file, treat it as --video
if (image && isVideoInput(image)) {
videoInputs.push(image);
image = undefined;
}
const hasVideo = videoInputs.length > 0;
const defaultPrompt = hasVideo ? "Describe the video." : "Describe the image.";
const prompt = (flags.prompt as string) || defaultPrompt;
if (!image && !hasVideo) {
if (isInteractive({ nonInteractive: config.nonInteractive })) {
const hint = await promptText({
message: "Enter image/video path or URL:",
});
if (!hint) {
process.stderr.write("Vision describe cancelled.\n");
process.exit(1);
}
// Detect if user entered a video
if (isVideoInput(hint)) {
videoInputs.push(hint);
} else {
image = hint;
}
} else {
throw new BailianError(
"Missing required argument --image or --video.",
ExitCode.USAGE,
"bl vision describe --image <path-or-url>\nbl vision describe --video <url-or-path>",
);
}
}
const format = detectOutputFormat(config.output);
if (config.dryRun) {
emitResult(
{ request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } },
format,
);
return;
}
const contentArray: ChatMessageContent[] = [];
// ---- Handle video inputs ----
if (videoInputs.length > 0) {
for (const videoInput of videoInputs) {
let videoUrl = videoInput;
// Local video file → upload to OSS
if (isLocalFile(videoInput)) {
if (!existsSync(videoInput)) {
throw new BailianError(`Video file not found: ${videoInput}`, ExitCode.USAGE);
}
const credential = await resolveCredential(config);
videoUrl = await resolveFileUrl(videoInput, credential.token, model);
}
contentArray.push({ type: "video_url", video_url: { url: videoUrl } });
}
}
// ---- Handle image input ----
if (image) {
const imageUrl = await toImageUrl(image);
let finalImageUrl = imageUrl;
if (isLocalFile(image) && imageUrl.startsWith("data:")) {
const { statSync } = await import("fs");
const fileSize = statSync(image).size;
if (fileSize > 5 * 1024 * 1024) {
const credential = await resolveCredential(config);
finalImageUrl = await resolveFileUrl(image, credential.token, model);
}
}
contentArray.push({ type: "image_url", image_url: { url: finalImageUrl } });
}
// ---- Text prompt ----
contentArray.push({ type: "text", text: prompt });
const body: ChatRequest = {
model,
messages: [
{
role: "user",
content: contentArray,
},
],
};
const url = chatEndpoint(config.baseUrl);
const response = await requestJson<ChatResponse>(config, {
url,
method: "POST",
body,
});
const content = response.choices?.[0]?.message?.content;
if (format !== "text") {
emitResult(response, format);
return;
}
emitBare((content || "") as string);
},
});
+192
View File
@@ -0,0 +1,192 @@
import {
BailianError,
ExitCode,
detectOutputFormat,
type OutputFormat,
CONSOLE_GATEWAY_NO_TOKEN_MESSAGE,
} from "bailian-cli-core";
import { API_KEY_PAGE } from "./urls.ts";
const LABEL_WIDTH = 13;
function pad(label: string): string {
return label.padEnd(LABEL_WIDTH);
}
function alignContinuation(text: string): string {
return text
.split("\n")
.map((line, i) => (i === 0 ? line : " ".repeat(LABEL_WIDTH) + line))
.join("\n");
}
function enhanceHint(err: BailianError): string | undefined {
if (err.exitCode === ExitCode.AUTH) {
if (err.message === CONSOLE_GATEWAY_NO_TOKEN_MESSAGE) {
return err.hint;
}
return [
err.hint,
"",
"bl auth login --api-key <your-key>",
"bl auth status",
`Get API Key: ${API_KEY_PAGE}`,
]
.filter((s): s is string => s !== undefined)
.join("\n");
}
return err.hint;
}
export function detectErrorOutputFormat(
argv: string[] = process.argv.slice(2),
envOutput: string | undefined = process.env.DASHSCOPE_OUTPUT,
): OutputFormat {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--output") {
return detectOutputFormat(argv[i + 1] || envOutput);
}
if (arg?.startsWith("--output=")) {
return detectOutputFormat(arg.slice("--output=".length));
}
}
return detectOutputFormat(envOutput);
}
function fromFetchFailed(err: TypeError): BailianError {
const cause = err.cause as NodeJS.ErrnoException | undefined;
const code = cause?.code;
const causeMsg = cause?.message;
const detailParts: string[] = [];
if (code) detailParts.push(code);
if (causeMsg && causeMsg !== code) detailParts.push(causeMsg);
const detail = detailParts.length > 0 ? detailParts.join(": ") : "unknown cause";
return new BailianError(
`Network request failed: ${detail}`,
ExitCode.NETWORK,
pickNetworkHint(code),
{ cause: err },
);
}
function pickNetworkHint(code: string | undefined): string {
switch (code) {
case "ENOTFOUND":
case "EAI_AGAIN":
return "DNS resolution failed. Check DASHSCOPE_BASE_URL or your DNS / network.";
case "ECONNREFUSED":
return "Connection refused. Check the target host/port and proxy settings.";
case "ECONNRESET":
return "Connection reset by peer. Retry, or check proxy / firewall.";
case "ETIMEDOUT":
return "Connection timed out. Check your network or try a different region.";
case "CERT_HAS_EXPIRED":
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
case "DEPTH_ZERO_SELF_SIGNED_CERT":
return "TLS certificate error. Check system clock and CA bundle.";
default:
return "Check network connection, proxy settings (HTTP_PROXY / HTTPS_PROXY), and DASHSCOPE_BASE_URL.";
}
}
function fromFsError(err: NodeJS.ErrnoException): BailianError {
const ecode = err.code;
let hint = "Check the file path and permissions.";
if (ecode === "ENOENT") hint = "File or directory not found.";
else if (ecode === "EACCES" || ecode === "EPERM")
hint = "Permission denied — check file or directory permissions.";
else if (ecode === "ENOSPC") hint = "Disk full — free up space and try again.";
return new BailianError(`File system error: ${err.message}`, ExitCode.GENERAL, hint, {
cause: err,
});
}
function writeCauseChain(err: Error): void {
let cur: unknown = (err as { cause?: unknown }).cause;
let depth = 0;
while (cur instanceof Error && depth < 5) {
process.stderr.write(`${pad("Caused by:")}${cur.message}\n`);
cur = (cur as { cause?: unknown }).cause;
depth++;
}
}
function writeBailianErrorText(err: BailianError): void {
process.stderr.write(`\n${pad("Error:")}${err.message}\n`);
const hint = enhanceHint(err);
if (hint) {
process.stderr.write(`${pad("Hint:")}${alignContinuation(hint)}\n`);
}
const api = err.api;
if (api) {
if (api.httpStatus !== undefined) {
const codeSuffix = api.apiCode ? ` (${api.apiCode})` : "";
process.stderr.write(`${pad("Status:")}HTTP ${api.httpStatus}${codeSuffix}\n`);
} else if (api.apiCode) {
process.stderr.write(`${pad("Code:")}${api.apiCode}\n`);
}
if (api.requestId) {
process.stderr.write(`${pad("Request ID:")}${api.requestId}\n`);
}
}
writeCauseChain(err);
process.stderr.write(`${pad("Exit code:")}${err.exitCode}\n`);
}
export function handleError(err: unknown): never {
if (err instanceof BailianError) {
const format = detectErrorOutputFormat();
if (format === "json") {
process.stderr.write(JSON.stringify(err.toJSON(), null, 2) + "\n");
} else {
writeBailianErrorText(err);
}
process.exit(err.exitCode);
}
if (err instanceof Error) {
if (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out")
) {
const timeout = new BailianError(
"Request timed out.",
ExitCode.TIMEOUT,
"Try increasing --timeout (e.g. --timeout 60).\n" +
"If this happens on every request with a valid API key, you may be hitting the wrong region.\n" +
"Run: bl auth status — to check your credentials and region.\n" +
"Run: bl config set --key region --value cn — to override the region.",
{ cause: err },
);
return handleError(timeout);
}
if (err instanceof TypeError && err.message === "fetch failed") {
return handleError(fromFetchFailed(err));
}
const ecode = (err as NodeJS.ErrnoException).code;
if (typeof ecode === "string" && ecode.startsWith("E")) {
return handleError(fromFsError(err as NodeJS.ErrnoException));
}
process.stderr.write(`\n${pad("Error:")}${err.message}\n`);
writeCauseChain(err);
if (process.env.DASHSCOPE_VERBOSE === "1") {
process.stderr.write(`${err.stack}\n`);
}
} else {
process.stderr.write(`\n${pad("Error:")}${String(err)}\n`);
}
process.exit(ExitCode.GENERAL);
}
+177
View File
@@ -0,0 +1,177 @@
import { scanCommandPath, parseFlags } from "./args.ts";
import { registry } from "./registry.ts";
import {
GLOBAL_OPTIONS,
loadConfig,
readConfigFile,
resolveCredential,
trackCommandExecution,
flushTelemetry,
type Region,
} from "bailian-cli-core";
import { ensureApiKey } from "./utils/ensure-key.ts";
import { handleError } from "./error-handler.ts";
import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts";
import { maybeShowStatusBar } from "./output/status-bar.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { CLI_VERSION } from "./version.ts";
import {
printCurrentCommandHelp,
registerCommandHelpPrinter,
setExecutingCommandPath,
} from "./utils/command-help.ts";
registerCommandHelpPrinter((commandPath, out) => {
const a = process.argv.slice(2);
const ri = a.indexOf("--region");
const region = ((ri >= 0 && a[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, out, region);
});
// 优雅处理 Ctrl+C
// 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会
// 落网络flush 与较短超时 race保证 SIGINT 仍然响应及时。
process.on("SIGINT", () => {
process.stderr.write("\nInterrupted. Exiting.\n");
void flushTelemetry(500).finally(() => process.exit(130));
});
// 优雅处理 stdout EPIPE例如管道到提前退出的 `mpv`
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
if (e.code === "EPIPE") process.exit(0);
else throw e;
});
// 自己接管鉴权 或 根本不需要 API key 的命令
const NO_AUTH_SETUP = [
["auth", "login"],
["auth", "logout"],
["config", "show"],
["config", "set"],
["config", "export-schema"],
["update"],
["knowledge", "retrieve"],
["pipeline", "run"],
["pipeline", "validate"],
["model", "list"],
["app", "list"],
["console", "call"],
["usage", "free"],
];
async function main() {
const argv = process.argv.slice(2);
if (argv.includes("--version") || argv.includes("-v")) {
process.stdout.write(`bl ${CLI_VERSION}\n`);
process.exit(0);
}
const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS);
if (argv.includes("--help") || argv.includes("-h")) {
const ri = argv.indexOf("--region");
const region = ((ri >= 0 && argv[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, process.stderr, region);
process.exit(0);
}
// 未传任何命令:展示帮助信息与登录引导
if (commandPath.length === 0) {
registry.printHelp([], process.stderr);
const flags = parseFlags(argv, GLOBAL_OPTIONS);
const config = loadConfig(flags);
config.clientName = "bailian-cli";
config.clientVersion = CLI_VERSION;
const hasKey = !!(
config.apiKey ||
config.fileApiKey ||
config.fileAccessToken ||
config.accessTokenEnv
);
if (hasKey) printQuickStart();
else printWelcomeBanner();
process.exit(0);
}
// 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出
if (registry.isGroupPath(commandPath)) {
const ri = argv.indexOf("--region");
const region = ((ri >= 0 && argv[ri + 1]) ||
process.env.DASHSCOPE_REGION ||
readConfigFile().region ||
"cn") as Region;
registry.printHelp(commandPath, process.stderr, region);
process.exit(0);
}
const { command, extra } = registry.resolve(commandPath);
const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]);
if (extra.length > 0) (flags as Record<string, unknown>)._positional = extra;
const config = loadConfig(flags);
config.clientName = "bailian-cli";
config.clientVersion = CLI_VERSION;
const needsAuthSetup = !NO_AUTH_SETUP.some((cmd) => cmd.every((c, i) => commandPath[i] === c));
if (needsAuthSetup) {
await ensureApiKey(config);
try {
const credential = await resolveCredential(config);
maybeShowStatusBar(config, credential.token, credential);
} catch {
/* 没有凭证,不展示状态栏 */
}
}
const updateCheckPromise = checkForUpdate(CLI_VERSION).catch(() => {});
setExecutingCommandPath(commandPath);
if (
commandPath[0] === "auth" &&
commandPath[1] === "login" &&
!flags.console &&
!String((flags.apiKey as string | undefined) ?? "").trim() &&
!String(config.apiKey ?? "").trim() &&
!process.env.DASHSCOPE_API_KEY?.trim()
) {
printCurrentCommandHelp(process.stderr);
process.exit(0);
}
await trackCommandExecution(config, commandPath, flags, () => command.execute(config, flags));
await updateCheckPromise;
const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update";
const newVersion = getPendingUpdateNotification();
if (newVersion && !config.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: ${CLI_VERSION}${newVersion}${reset}\n`);
process.stderr.write(` Run ${cyan}bl update${reset} to upgrade\n\n`);
}
// 进程退出前尽力等待在途的埋点完成。
// 使用较短超时兜底,避免慢网拖慢用户感知。
await flushTelemetry(1000);
}
main().catch((err) => {
// 在 handleError() 调用 process.exit() 之前刷出在途埋点。
// 命令抛出的错误已被 trackCommandExecution 的 finally 块记录,
// 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话,
// 错误事件会随进程退出丢掉。
void flushTelemetry(1000).finally(() => handleError(err));
});
+34
View File
@@ -0,0 +1,34 @@
import { API_KEY_PAGE } from "../urls.ts";
const QUICK_START_TASKS = [
"帮我生成一套鸭舌帽的亚马逊电商主图(白底 + 场景图 + 模特上身图)",
"帮我生成一段 3 分钟的幽默相声音频",
"帮我生成一套小红帽故事绘本 PDF(含插图)",
"帮我分析这个视频的内容并写一篇小红书文案",
];
function colors() {
const isTTY = process.stderr.isTTY;
return {
purple: isTTY ? "\x1b[38;2;147;51;234m" : "",
dim: isTTY ? "\x1b[2m" : "",
reset: isTTY ? "\x1b[0m" : "",
};
}
export function printWelcomeBanner(): void {
const { purple, reset } = colors();
process.stderr.write(`\n Welcome to ${purple}Bailian${reset} CLI!\n\n`);
process.stderr.write(" Get started in 2 steps:\n");
process.stderr.write(` 1. Get your API Key: ${API_KEY_PAGE}\n`);
process.stderr.write(" 2. Login: bl auth login --api-key <your-key>\n\n");
}
export function printQuickStart(): void {
const { dim, reset } = colors();
process.stderr.write("\n🎯 Try these with your AI coding assistant:\n\n");
QUICK_START_TASKS.forEach((task, i) => {
process.stderr.write(`${dim}${i + 1}${reset} ${task}\n`);
});
process.stderr.write("\n");
}
+23
View File
@@ -0,0 +1,23 @@
import { formatOutput, type OutputFormat } from "bailian-cli-core";
/**
* Emit the primary result of a command.
*
* Design principle:
* stdout → structured data only (JSON when piped, text when TTY)
* stderr → human info (progress, logs, tips) — handled elsewhere
*
* This ensures `bl cmd ... | jq .` always receives clean JSON,
* while interactive users see human-readable text.
*/
export function emitResult(data: unknown, format: OutputFormat): void {
process.stdout.write(formatOutput(data, format) + "\n");
}
/**
* Emit a bare value (file path, plain text) to stdout.
* Used in --quiet mode or when the result is a single scalar.
*/
export function emitBare(value: string): void {
process.stdout.write(value + "\n");
}
+66
View File
@@ -0,0 +1,66 @@
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export interface Spinner {
start(): void;
update(text: string): void;
stop(finalText?: string): void;
}
export function createSpinner(label: string): Spinner {
const isTTY = process.stderr.isTTY;
let frame = 0;
let interval: ReturnType<typeof setInterval> | null = null;
let currentLabel = label;
return {
start() {
if (!isTTY) return;
interval = setInterval(() => {
process.stderr.write(`\r${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentLabel}`);
frame++;
}, 80);
},
update(text: string) {
currentLabel = text;
},
stop(finalText?: string) {
if (interval) {
clearInterval(interval);
interval = null;
}
if (isTTY) {
process.stderr.write("\r\x1b[K");
if (finalText) {
process.stderr.write(`${finalText}\n`);
}
}
},
};
}
export interface ProgressBar {
update(current: number): void;
finish(): void;
}
export function createProgressBar(total: number, label = ""): ProgressBar {
const isTTY = process.stderr.isTTY;
const width = 30;
return {
update(current: number) {
if (!isTTY) return;
const pct = Math.min(1, current / total);
const filled = Math.round(width * pct);
const empty = width - filled;
const bar = "█".repeat(filled) + "░".repeat(empty);
const pctStr = `${Math.round(pct * 100)}%`;
process.stderr.write(`\r${label} ${bar} ${pctStr}`);
},
finish() {
if (isTTY) {
process.stderr.write("\n");
}
},
};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Interactive prompt utilities.
*
* Wraps @clack/prompts with environment-awareness:
* - In interactive mode: shows prompts and lets users input values.
* - In non-interactive / CI / Agent mode: fails fast with a clear error.
*
* All functions here are no-ops (return undefined) when non-interactive,
* so callers must check isInteractive() first or handle the missing-value
* case explicitly.
*/
import { BailianError, ExitCode, isInteractive } from "bailian-cli-core";
import { printCurrentCommandHelp, getExecutingCommandPath } from "../utils/command-help.ts";
// Dynamic import to avoid loading @clack/prompts in non-interactive envs unnecessarily
// (though for CLI tools the startup cost is usually acceptable)
/**
* Prompt the user for a text value.
* Only call this when isInteractive() is true; otherwise the function returns
* undefined immediately so the caller can fail fast.
*/
export async function promptText(options: {
message: string;
defaultValue?: string;
}): Promise<string | undefined> {
if (!isInteractive()) return undefined;
const { defaultValue, message } = options;
const inquirer = (await import("@clack/prompts")) as {
text: (opts: {
message: string;
default?: string;
placeholder?: string;
}) => Promise<string | symbol>;
};
const val = await inquirer.text({
message,
default: defaultValue,
placeholder: defaultValue,
});
// @clack/prompts returns a Symbol.cancel when the user presses Ctrl+C
if (typeof val === "symbol") return undefined;
return val as string;
}
/**
* Like promptText but confirms with y/N before proceeding.
*/
export async function promptConfirm(options: {
message: string;
initialValue?: boolean;
}): Promise<boolean | undefined> {
if (!isInteractive()) return undefined;
const { message, initialValue } = options;
const inquirer = (await import("@clack/prompts")) as {
confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean | symbol>;
};
const val = await inquirer.confirm({ message, initialValue });
if (typeof val === "symbol") return undefined;
return val as boolean;
}
/**
* Prompt the user to select one value from a list.
* Only call this when isInteractive() is true; otherwise the function returns
* undefined immediately so the caller can fail fast.
*/
export async function promptSelect(options: {
message: string;
choices: Array<{ value: string; label: string; hint?: string }>;
defaultValue?: string;
}): Promise<string | undefined> {
if (!isInteractive()) return undefined;
const { message, choices, defaultValue } = options;
const clack = (await import("@clack/prompts")) as {
select: (opts: {
message: string;
initialValue?: string;
options: Array<{ value: string; label: string; hint?: string }>;
}) => Promise<string | symbol>;
};
const val = await clack.select({
message,
initialValue: defaultValue,
options: choices,
});
if (typeof val === "symbol") return undefined;
return val as string;
}
/**
* Fail fast with a user-friendly error when a required option is missing
* in non-interactive (agent / CI) mode.
*/
export function failIfMissing(flagName: string, context: string): never {
if (getExecutingCommandPath().length > 0) {
printCurrentCommandHelp(process.stderr);
process.exit(0);
}
throw new BailianError(
`Missing required argument: --${flagName}\n` +
`Hint: In non-interactive (CI / agent) environments all required flags must be provided.\n` +
` In an interactive terminal, run without --${flagName} and the CLI will prompt for it.`,
ExitCode.USAGE,
context,
);
}
+39
View File
@@ -0,0 +1,39 @@
import { homedir } from "os";
import { maskToken, type Config, type ResolvedCredential } from "bailian-cli-core";
const reset = "\x1b[0m";
const dim = "\x1b[2m";
const bold = "\x1b[1m";
const mmBlue = "\x1b[38;2;43;82;255m";
const mmCyan = "\x1b[38;2;6;184;212m";
const mmPink = "\x1b[38;2;236;72;153m";
function tildePath(p: string): string {
return p.startsWith(homedir()) ? p.replace(homedir(), "~") : p;
}
export function maybeShowStatusBar(
config: Config,
token: string,
resolved?: ResolvedCredential,
): void {
if (config.quiet || !process.stderr.isTTY) return;
const filePath = config.configPath ? tildePath(config.configPath) : "~/.bailian/config.json";
const regionSrc = config.fileRegion ? `${config.fileRegion} (file)` : "cn (default)";
const authTag = resolved
? `${resolved.source} · ${resolved.method}`
: config.apiKey
? "flag · api-key"
: "config";
const maskedKey = maskToken(token);
process.stderr.write(
`${bold}${mmBlue}BAILIAN${reset} ` +
`${dim}${filePath}${reset} ` +
`${dim}|${reset} ` +
`${dim}Region:${reset} ${mmCyan}${regionSrc}${reset} ` +
`${dim}|${reset} ` +
`${dim}Auth:${reset} ${mmPink}${maskedKey}${reset} ${dim}${authTag}${reset}\n`,
);
}
+24
View File
@@ -0,0 +1,24 @@
import { loadConfig, type Config, type GlobalFlags } from "bailian-cli-core";
const PIPELINE_FLAGS: GlobalFlags = {
output: "json",
nonInteractive: true,
noColor: true,
quiet: true,
verbose: false,
yes: false,
dryRun: false,
help: false,
async: false,
};
/**
* 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.
*/
export function buildPipelineConfig(): Config {
const config = loadConfig(PIPELINE_FLAGS);
config.clientName = "bailian-cli";
return config;
}
+96
View File
@@ -0,0 +1,96 @@
import { PipelineError } from "./errors.ts";
import type { StepHandler, StepResult, StepContext, StepOutputSchema } from "./types.ts";
export class StepDispatcher {
private readonly handlers = new Map<string, StepHandler>();
private readonly outputSchemas = new Map<string, StepOutputSchema>();
registerStep(id: string, handler: StepHandler, outputSchema?: StepOutputSchema): void {
this.handlers.set(id, handler);
if (outputSchema) this.outputSchemas.set(id, outputSchema);
}
executeStep(
id: string,
input: Record<string, unknown>,
ctx: StepContext,
): Promise<StepResult> | StepResult {
const handler = this.handlers.get(id);
if (!handler) {
throw new PipelineError("unknown_step", `Unknown step type: ${id}`, {
details: { available: this.listSteps() },
});
}
return handler(input, ctx);
}
hasStep(id: string): boolean {
return this.handlers.has(id);
}
listSteps(): string[] {
return Array.from(this.handlers.keys()).sort();
}
getOutputSchema(id: string): StepOutputSchema | undefined {
return this.outputSchemas.get(id);
}
}
const defaultDispatcher = new StepDispatcher();
export function createStepDispatcher(): StepDispatcher {
return new StepDispatcher();
}
export function getDefaultStepDispatcher(): StepDispatcher {
return defaultDispatcher;
}
export function registerStep(
id: string,
handler: StepHandler,
dispatcher?: StepDispatcher,
outputSchema?: StepOutputSchema,
): void;
export function registerStep(
id: string,
handler: StepHandler,
outputSchema?: StepOutputSchema,
): void;
export function registerStep(
id: string,
handler: StepHandler,
dispatcherOrSchema?: StepDispatcher | StepOutputSchema,
outputSchema?: StepOutputSchema,
): void {
let dispatcher: StepDispatcher;
let schema: StepOutputSchema | undefined;
if (dispatcherOrSchema instanceof StepDispatcher) {
dispatcher = dispatcherOrSchema;
schema = outputSchema;
} else {
dispatcher = defaultDispatcher;
schema = dispatcherOrSchema ?? outputSchema;
}
dispatcher.registerStep(id, handler, schema);
}
export function executeStep(
id: string,
input: Record<string, unknown>,
ctx: StepContext,
dispatcher = defaultDispatcher,
): Promise<StepResult> | StepResult {
return dispatcher.executeStep(id, input, ctx);
}
export function hasStep(id: string, dispatcher = defaultDispatcher): boolean {
return dispatcher.hasStep(id);
}
export function listSteps(dispatcher = defaultDispatcher): string[] {
return dispatcher.listSteps();
}
+43
View File
@@ -0,0 +1,43 @@
import type { StructuredStepErrorShape } from "./types.ts";
export class PipelineError extends Error {
readonly code: string;
readonly step?: string;
readonly details?: Record<string, unknown>;
constructor(
code: string,
message: string,
options?: { step?: string; details?: Record<string, unknown> },
) {
super(message);
this.name = "PipelineError";
this.code = code;
this.step = options?.step;
this.details = options?.details;
}
toJSON(): StructuredStepErrorShape {
return {
code: this.code,
message: this.message,
...(this.step ? { step: this.step } : {}),
...(this.details ? { details: this.details } : {}),
};
}
}
export class PipelineValidationError extends PipelineError {
readonly issues: string[];
constructor(message: string, issues: string[], step?: string) {
super("pipeline_validation_error", message, { step, details: { issues } });
this.issues = issues;
}
}
export function toPipelineError(err: unknown, step?: string): PipelineError {
if (err instanceof PipelineError) return err;
const message = err instanceof Error ? err.message : String(err);
return new PipelineError("step_execution_error", message, { step });
}
+706
View File
@@ -0,0 +1,706 @@
import { PipelineError, toPipelineError } from "./errors.ts";
import { buildPipelineConfig } from "./bl-config.ts";
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
import {
evaluateCondition,
redactSensitiveOutput,
redactStructuredError,
resolvePlannedStepInput,
resolveStepInput,
} from "./expressions.ts";
import {
buildExecutionPlan,
nextReadySteps,
normalizeConcurrency,
orderReports,
topologicalOrder,
} from "./scheduler.ts";
import {
OUTPUT_PATH_KEYS,
checkInputPaths,
parseTimeoutSeconds,
preflightCheckInputFiles,
resolveInputPaths,
} from "./utils.ts";
import { collectPipelineIssues, validatePipelineRuntimeInput } from "./validation.ts";
import {
WORKFLOW_VERSION,
type ExecutePipelineOptions,
type PipelineDefinition,
type PipelineEventInputSummary,
type PipelineEventOutputSummary,
type PipelineEventStep,
type PipelineEventTiming,
type PipelineExecutionReport,
type PipelineLifecycleEvent,
type PipelinePlanStep,
type PipelineRetryPolicy,
type PipelineStep,
type PipelineStepReport,
type StepArtifact,
type StepContext,
type StepResult,
type StructuredStepErrorShape,
} from "./types.ts";
export { validatePipeline } from "./validation.ts";
const RETRY_BACKOFF_BASE_MS = 100;
export async function executePipeline(
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown> = {},
options: ExecutePipelineOptions = {},
): Promise<PipelineExecutionReport> {
return await executePipelineInternal(pipeline, runtimeInput, options);
}
export async function* streamPipelineEvents(
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown> = {},
options: Pick<
ExecutePipelineOptions,
| "concurrency"
| "basePath"
| "dryRun"
| "signal"
| "timeoutSeconds"
| "blRequestTimeoutSeconds"
| "stepDispatcher"
> = {},
): AsyncGenerator<PipelineLifecycleEvent> {
const queue = new AsyncEventQueue<PipelineLifecycleEvent>(1024);
const controller = new AbortController();
const abortFromParent = () => controller.abort();
if (options.signal?.aborted) abortFromParent();
else options.signal?.addEventListener("abort", abortFromParent, { once: true });
let done = false;
let error: unknown;
void executePipelineInternal(pipeline, runtimeInput, {
...options,
signal: controller.signal,
onEvent: async (event) => {
await queue.push(event);
},
}).then(
() => {
done = true;
queue.close();
},
(err) => {
error = err;
done = true;
queue.close();
},
);
try {
while (!done || queue.size > 0) {
const event = await queue.shift();
if (event) {
yield event;
continue;
}
if (error) throw error;
}
if (error) throw error;
} finally {
controller.abort();
options.signal?.removeEventListener("abort", abortFromParent);
queue.close();
}
}
async function executePipelineInternal(
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
options: ExecutePipelineOptions,
): Promise<PipelineExecutionReport> {
const stepDispatcher = options.stepDispatcher ?? getDefaultStepDispatcher();
const issues = collectPipelineIssues(pipeline, stepDispatcher);
if (issues.length > 0) {
throw new PipelineError("pipeline_validation_error", "Invalid pipeline definition", {
details: { issues },
});
}
const normalizedRuntimeInput = validatePipelineRuntimeInput(pipeline, runtimeInput);
if (options.basePath) {
const preflightIssues = await preflightCheckInputFiles(pipeline.steps, options.basePath);
if (preflightIssues.length > 0) {
throw new PipelineError("pipeline_file_not_found", preflightIssues.join("; "), {
details: { issues: preflightIssues },
});
}
}
const blConfig = buildPipelineConfig();
const plan = buildExecutionPlan(pipeline);
const concurrency = normalizeConcurrency(options.concurrency);
const reports: PipelineStepReport[] = [];
const reportByStep = new Map<string, PipelineStepReport>();
const outputs = new Map<string, StepResult>();
const artifacts: StepArtifact[] = [];
const emit = async (event: PipelineLifecycleEvent) => {
await options.onEvent?.(event);
};
await emit({
type: "pipeline.started",
timestamp: now(),
status: options.dryRun ? "planned" : "running",
stepCount: pipeline.steps.length,
dryRun: !!options.dryRun,
});
if (options.dryRun) {
for (const planStep of topologicalOrder(plan)) {
const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput);
const report: PipelineStepReport = {
id: planStep.step.id,
type: planStep.step.type,
status: "planned",
dependencies: planStep.dependencies,
input: resolved.redacted,
...(planStep.step.when !== undefined ? { condition: "pending" } : {}),
};
reports.push(report);
await emit({
type: "step.input.resolved",
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
});
await emit({
type: "step.planned",
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}),
});
}
await emit({
type: "pipeline.planned",
timestamp: now(),
status: "planned",
stepCount: reports.length,
artifactCount: artifacts.length,
});
return { status: "planned", version: WORKFLOW_VERSION, steps: reports, artifacts };
}
const remaining = new Set(plan.map((item) => item.step.id));
const inFlight = new Map<string, Promise<PipelineStepReport>>();
while (remaining.size > 0 || inFlight.size > 0) {
let progressed = false;
for (const planStep of nextReadySteps(plan, remaining, reportByStep)) {
if (inFlight.size >= concurrency) break;
if (!remaining.has(planStep.step.id)) continue;
const dependencyReports = planStep.dependencies.map((id) => reportByStep.get(id));
const failedDependency = dependencyReports.find((report) => report?.status === "failed");
if (failedDependency) {
const reason = `dependency ${failedDependency.id} failed`;
pushReport(reports, reportByStep, {
id: planStep.step.id,
type: planStep.step.type,
status: "skipped",
dependencies: planStep.dependencies,
skipReason: reason,
});
await emit({
type: "step.skipped",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
reason,
});
remaining.delete(planStep.step.id);
progressed = true;
continue;
}
const allDependenciesSkipped =
dependencyReports.length > 0 &&
dependencyReports.every((report) => report?.status === "skipped");
if (allDependenciesSkipped) {
const reason = "all dependencies skipped";
pushReport(reports, reportByStep, {
id: planStep.step.id,
type: planStep.step.type,
status: "skipped",
dependencies: planStep.dependencies,
skipReason: reason,
});
await emit({
type: "step.skipped",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
reason,
});
remaining.delete(planStep.step.id);
progressed = true;
continue;
}
const condition =
planStep.step.when !== undefined
? evaluateCondition(planStep.step.when, pipeline, normalizedRuntimeInput, outputs)
: true;
if (!condition) {
const reason = "condition evaluated to false";
pushReport(reports, reportByStep, {
id: planStep.step.id,
type: planStep.step.type,
status: "skipped",
dependencies: planStep.dependencies,
skipReason: reason,
condition: "false",
});
await emit({
type: "step.skipped",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
reason,
});
remaining.delete(planStep.step.id);
progressed = true;
continue;
}
const executing = executePlanStep(
planStep,
pipeline,
normalizedRuntimeInput,
outputs,
artifacts,
emit,
options,
blConfig,
stepDispatcher,
);
inFlight.set(planStep.step.id, executing);
remaining.delete(planStep.step.id);
progressed = true;
}
if (inFlight.size > 0) {
const report = await Promise.race(inFlight.values());
inFlight.delete(report.id);
pushReport(reports, reportByStep, report);
progressed = true;
}
if (!progressed) {
throw new PipelineError("pipeline_graph_error", "Workflow graph did not make progress", {
details: { remaining: Array.from(remaining) },
});
}
}
const orderedReports = orderReports(plan, reports);
const failedReport = orderedReports.find((report) => report.status === "failed");
if (failedReport?.error) {
const failedPlanStep = plan.find((item) => item.step.id === failedReport.id);
await emit({
type: "pipeline.failed",
timestamp: now(),
status: "failed",
stepCount: orderedReports.length,
artifactCount: artifacts.length,
failedStep: failedPlanStep
? stepEvent(failedPlanStep)
: {
id: failedReport.id,
type: failedReport.type,
dependencies: failedReport.dependencies,
index: orderedReports.findIndex((report) => report.id === failedReport.id) + 1,
total: orderedReports.length,
},
error: failedReport.error,
});
const failedResult: PipelineExecutionReport = {
status: "failed",
version: WORKFLOW_VERSION,
steps: orderedReports,
artifacts,
};
return failedResult;
}
await emit({
type: "pipeline.succeeded",
timestamp: now(),
status: "succeeded",
stepCount: orderedReports.length,
artifactCount: artifacts.length,
});
const successResult: PipelineExecutionReport = {
status: "succeeded",
version: WORKFLOW_VERSION,
steps: orderedReports,
artifacts,
};
return successResult;
}
async function executePlanStep(
planStep: PipelinePlanStep,
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
outputs: Map<string, StepResult>,
artifacts: StepArtifact[],
emit: (event: PipelineLifecycleEvent) => Promise<void>,
options: ExecutePipelineOptions,
blConfig: unknown,
stepDispatcher: StepDispatcher,
): Promise<PipelineStepReport> {
const maxAttempts = Math.max(1, Math.floor(planStep.step.retry?.maxAttempts ?? 1));
let lastError: StructuredStepErrorShape | undefined;
let lastRedactedInput: Record<string, unknown> | undefined;
let lastSensitive = false;
let startedAt = new Date().toISOString();
let finishedAt = startedAt;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
startedAt = new Date().toISOString();
await emit({
type: "step.started",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
timing: { startedAt },
attempt,
});
try {
const resolved = resolveStepInput(planStep.step, pipeline, runtimeInput, outputs);
let resolvedInput = resolved.value;
let resolvedRedacted = resolved.redacted;
if (options.basePath) {
const paths = resolveInputPaths(resolvedInput, options.basePath);
resolvedInput = paths.input;
resolvedRedacted = resolveInputPaths(
resolved.redacted as Record<string, unknown>,
options.basePath,
).input;
const fileIssues = await checkInputPaths(
resolvedInput,
paths.resolvedKeys.filter((k) => !OUTPUT_PATH_KEYS.includes(k)),
planStep.step.id,
);
if (fileIssues.length > 0) {
throw new PipelineError("pipeline_file_not_found", fileIssues.join("; "), {
step: planStep.step.type,
details: { issues: fileIssues },
});
}
}
lastRedactedInput = resolvedRedacted as Record<string, unknown>;
lastSensitive = resolved.sensitive;
await emit({
type: "step.input.resolved",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
input: inputSummary(resolvedRedacted as Record<string, unknown>, resolved.sensitiveKeys),
});
const output = await executeWithTimeout(
planStep.step,
resolvedInput,
options,
stepEvent(planStep),
emit,
blConfig,
stepDispatcher,
);
outputs.set(planStep.step.id, output);
const reportOutput = resolved.sensitive ? redactSensitiveOutput(output) : output;
if (reportOutput.artifacts) artifacts.push(...reportOutput.artifacts);
finishedAt = new Date().toISOString();
for (const artifact of reportOutput.artifacts ?? []) {
await emit({
type: "artifact.created",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
artifact,
});
}
await emit({
type: "step.succeeded",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
timing: timing(startedAt, finishedAt),
output: outputSummary(reportOutput),
attempt,
...(reportOutput.warnings && reportOutput.warnings.length > 0
? { warnings: reportOutput.warnings }
: {}),
});
return {
id: planStep.step.id,
type: planStep.step.type,
status: "succeeded",
dependencies: planStep.dependencies,
input: resolved.redacted,
output: reportOutput,
startedAt,
finishedAt,
attempts: attempt,
...(planStep.step.when !== undefined ? { condition: "true" as const } : {}),
};
} catch (err) {
const pipelineError = toPipelineError(err, planStep.step.type);
lastError = lastSensitive
? redactStructuredError(pipelineError.toJSON())
: pipelineError.toJSON();
finishedAt = new Date().toISOString();
if (attempt < maxAttempts) {
await emit({
type: "step.retrying",
timestamp: now(),
status: "running",
step: stepEvent(planStep),
attempt,
nextAttempt: attempt + 1,
error: lastError,
});
const delayMs = retryDelayMs(
planStep.step.retry?.backoff,
attempt,
options.retryDelayBaseMs ?? RETRY_BACKOFF_BASE_MS,
);
if (delayMs > 0) await (options.sleep ?? sleep)(delayMs);
continue;
}
}
}
const error = lastError ?? {
code: "pipeline_step_failed",
message: `Step ${planStep.step.id} failed`,
step: planStep.step.type,
details: {},
};
await emit({
type: "step.failed",
timestamp: now(),
status: "failed",
step: stepEvent(planStep),
timing: timing(startedAt, finishedAt),
attempt: maxAttempts,
error,
});
return {
id: planStep.step.id,
type: planStep.step.type,
status: "failed",
dependencies: planStep.dependencies,
...(lastRedactedInput ? { input: lastRedactedInput } : {}),
error,
startedAt,
finishedAt,
attempts: maxAttempts,
};
}
async function executeWithTimeout(
step: PipelineStep,
input: Record<string, unknown>,
options: ExecutePipelineOptions,
planStepEvent: PipelineEventStep,
emit: (event: PipelineLifecycleEvent) => Promise<void>,
blConfig: unknown,
stepDispatcher: StepDispatcher,
): Promise<StepResult> {
const timeoutSeconds = parseTimeoutSeconds(step.timeout) ?? options.timeoutSeconds;
const emitEvent = async (event: Record<string, unknown>) => {
if (event.type === "step.polling") {
await emit({
...event,
type: "step.polling",
timestamp: (event.timestamp as string) ?? now(),
status: "running",
step: planStepEvent,
taskId: event.taskId as string,
taskStatus: event.taskStatus as string,
elapsedMs: event.elapsedMs as number,
pollAttempt: event.pollAttempt as number,
});
}
};
const ctx: StepContext = {
dryRun: false,
signal: options.signal,
timeoutSeconds,
blRequestTimeoutSeconds: options.blRequestTimeoutSeconds,
emitEvent,
blConfig,
};
if (!timeoutSeconds) return await stepDispatcher.executeStep(step.type, input, ctx);
const controller = new AbortController();
const abortFromParent = () => controller.abort();
if (options.signal?.aborted) abortFromParent();
else options.signal?.addEventListener("abort", abortFromParent, { once: true });
const timeoutMs = timeoutSeconds * 1000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
ctx.signal = controller.signal;
try {
return await Promise.race([
stepDispatcher.executeStep(step.type, input, ctx),
new Promise<StepResult>((_, reject) => {
controller.signal.addEventListener(
"abort",
() => {
reject(
new PipelineError(
"step_timeout",
`Step ${step.id} exceeded timeout ${timeoutSeconds}s`,
{ step: step.type },
),
);
},
{ once: true },
);
}),
]);
} finally {
clearTimeout(timeout);
options.signal?.removeEventListener("abort", abortFromParent);
}
}
function pushReport(
reports: PipelineStepReport[],
reportByStep: Map<string, PipelineStepReport>,
report: PipelineStepReport,
): void {
reports.push(report);
reportByStep.set(report.id, report);
}
function now(): string {
return new Date().toISOString();
}
function stepEvent(planStep: PipelinePlanStep): PipelineEventStep {
return {
id: planStep.step.id,
type: planStep.step.type,
dependencies: planStep.dependencies,
index: planStep.index,
total: planStep.total,
};
}
function inputSummary(
input: Record<string, unknown>,
sensitiveKeys: string[] = [],
): PipelineEventInputSummary {
return {
keys: Object.keys(input).sort(),
...(sensitiveKeys.length > 0 ? { redactedKeys: sensitiveKeys.sort() } : {}),
};
}
function outputSummary(output: StepResult): PipelineEventOutputSummary {
return {
...(output.data !== undefined ? { dataType: dataType(output.data) } : {}),
artifactCount: output.artifacts?.length ?? 0,
warningCount: output.warnings?.length ?? 0,
...(output.metadata ? { metadata: output.metadata } : {}),
};
}
function dataType(value: unknown): string {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
function timing(startedAt: string, finishedAt: string): PipelineEventTiming {
return {
startedAt,
finishedAt,
durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
};
}
function retryDelayMs(
backoff: PipelineRetryPolicy["backoff"] = "none",
failedAttempt: number,
baseMs: number,
): number {
if (backoff === "none") return 0;
if (backoff === "linear") return baseMs * failedAttempt;
return baseMs * 2 ** (failedAttempt - 1);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class AsyncEventQueue<T> {
private readonly items: T[] = [];
private readonly takers: Array<(value: T | undefined) => void> = [];
private readonly pushWaiters: Array<() => void> = [];
private closed = false;
private readonly maxSize: number;
constructor(maxSize: number) {
this.maxSize = maxSize;
}
get size(): number {
return this.items.length;
}
async push(item: T): Promise<void> {
while (!this.closed && this.items.length >= this.maxSize) {
await new Promise<void>((resolve) => this.pushWaiters.push(resolve));
}
if (this.closed) return;
const taker = this.takers.shift();
if (taker) {
taker(item);
return;
}
this.items.push(item);
}
async shift(): Promise<T | undefined> {
const item = this.items.shift();
if (item !== undefined) {
this.wakePushWaiter();
return item;
}
if (this.closed) return undefined;
return await new Promise<T | undefined>((resolve) => this.takers.push(resolve));
}
close(): void {
if (this.closed) return;
this.closed = true;
for (const taker of this.takers.splice(0)) taker(undefined);
for (const waiter of this.pushWaiters.splice(0)) waiter();
}
private wakePushWaiter(): void {
this.pushWaiters.shift()?.();
}
}
+503
View File
@@ -0,0 +1,503 @@
import { PipelineError } from "./errors.ts";
import { getByJsonPointer } from "./schema.ts";
import type {
StepArtifact,
StepResult,
StructuredStepErrorShape,
PipelineBinding,
PipelineConditionExpression,
PipelineDefinition,
PipelineInputExpression,
PipelineStep,
ResolvedExpression,
} from "./types.ts";
import { isRecord } from "./utils.ts";
const REDACTED = "[redacted]";
function stringifyConcatValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value) ?? "";
return String(value as string | number | boolean | bigint | symbol);
}
export function resolveStepInput(
step: PipelineStep,
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
outputs: Map<string, StepResult>,
): {
value: Record<string, unknown>;
redacted: Record<string, unknown>;
sensitiveKeys: string[];
sensitive: boolean;
} {
const value: Record<string, unknown> = {};
const redacted: Record<string, unknown> = {};
const sensitiveKeys: string[] = [];
let sensitive = false;
for (const [key, expression] of Object.entries(step.input)) {
const resolved = resolveExpression(expression, pipeline, runtimeInput, outputs);
value[key] = resolved.value;
redacted[key] = resolved.redacted;
if (resolved.sensitive) {
sensitive = true;
sensitiveKeys.push(key);
}
}
return { value, redacted, sensitiveKeys, sensitive };
}
export function resolvePlannedStepInput(
step: PipelineStep,
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
): { redacted: Record<string, unknown>; sensitiveKeys: string[] } {
const redacted: Record<string, unknown> = {};
const sensitiveKeys: string[] = [];
for (const [key, expression] of Object.entries(step.input)) {
const resolved = resolvePlannedExpression(expression, pipeline, runtimeInput);
redacted[key] = resolved.redacted;
if (resolved.sensitive) sensitiveKeys.push(key);
}
return { redacted, sensitiveKeys };
}
export function evaluateCondition(
expression: PipelineConditionExpression,
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
outputs: Map<string, StepResult>,
): boolean {
return evaluateConditionWithResolver(expression, (inputExpression) =>
resolveExpression(inputExpression, pipeline, runtimeInput, outputs),
);
}
export function evaluateResolvedCondition(expression: PipelineConditionExpression): boolean {
return evaluateConditionWithResolver(expression, (inputExpression) =>
combineResolved(inputExpression, inputExpression, false, inputExpression !== undefined),
);
}
export function redactSensitiveOutput(output: StepResult): StepResult {
return {
...(output.data !== undefined ? { data: REDACTED } : {}),
...(output.artifacts ? { artifacts: output.artifacts.map(redactArtifact) } : {}),
...(output.warnings
? {
warnings: output.warnings.map((warning) => ({
code: warning.code,
message: warning.message,
})),
}
: {}),
metadata: {
...(output.metadata?.step ? { step: output.metadata.step } : {}),
redacted: true,
},
};
}
export function redactStructuredError(error: StructuredStepErrorShape): StructuredStepErrorShape {
return {
code: error.code,
message: REDACTED,
...(error.step ? { step: error.step } : {}),
details: { redacted: true },
};
}
function resolveExpression(
expression: PipelineInputExpression,
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
outputs: Map<string, StepResult>,
): ResolvedExpression {
if (Array.isArray(expression)) {
const items = expression.map((item) =>
resolveExpression(item, pipeline, runtimeInput, outputs),
);
return combineResolved(
items.map((item) => item.value),
items.map((item) => item.redacted),
items.some((item) => item.sensitive),
);
}
if (!isExpressionObject(expression)) {
if (!isRecord(expression))
return combineResolved(expression, expression, false, expression !== undefined);
const value: Record<string, unknown> = {};
const redacted: Record<string, unknown> = {};
let sensitive = false;
for (const [key, child] of Object.entries(expression)) {
const resolved = resolveExpression(child, pipeline, runtimeInput, outputs);
value[key] = resolved.value;
redacted[key] = resolved.redacted;
sensitive = sensitive || resolved.sensitive;
}
return combineResolved(value, redacted, sensitive);
}
if ("$input" in expression) {
const value = getByJsonPointer(runtimeInput, expression.$input as string);
return combineResolved(value, value, false, value !== undefined);
}
if ("$from" in expression) {
const from = expression.$from as string;
const output = outputs.get(from);
if (!output)
throw new PipelineError("pipeline_reference_error", `Step output not found: ${from}`);
const pointer = typeof expression.path === "string" ? expression.path : "";
const value = getByJsonPointer(output, pointer);
if (value === undefined)
throw new PipelineError(
"pipeline_reference_error",
`Step output path not found: ${from}${pointer}`,
);
return combineResolved(value, value, false, true);
}
if ("$env" in expression) {
const value = resolveBinding(pipeline.env, expression.$env as string, false);
return combineResolved(value, value, false, value !== undefined);
}
if ("$secret" in expression) {
const value = resolveBinding(pipeline.secrets, expression.$secret as string, true);
return combineResolved(value, REDACTED, true, value !== undefined);
}
if ("$concat" in expression) {
const items = (expression.$concat as PipelineInputExpression[]).map((item) =>
resolveExpression(item, pipeline, runtimeInput, outputs),
);
const value = items.map((item) => stringifyConcatValue(item.value)).join("");
const sensitive = items.some((item) => item.sensitive);
const redacted = items
.map((item) => (item.sensitive ? REDACTED : stringifyConcatValue(item.value)))
.join("");
return combineResolved(value, redacted, sensitive);
}
if ("$coalesce" in expression) {
const candidates = expression.$coalesce as PipelineInputExpression[];
let lastError: unknown;
for (const candidate of candidates) {
try {
const resolved = resolveExpression(candidate, pipeline, runtimeInput, outputs);
if (resolved.exists && resolved.value !== undefined && resolved.value !== null) {
return resolved;
}
} catch (err) {
lastError = err;
continue;
}
}
if (lastError) throw lastError;
return combineResolved(undefined, undefined, false, false);
}
if ("$js" in expression) {
const code = expression.$js as string;
const argsExpressions = (expression.args ?? {}) as Record<string, PipelineInputExpression>;
const resolvedArgs: Record<string, unknown> = {};
const redactedArgs: Record<string, unknown> = {};
let sensitive = false;
for (const [key, argExpr] of Object.entries(argsExpressions)) {
const resolved = resolveExpression(argExpr, pipeline, runtimeInput, outputs);
resolvedArgs[key] = resolved.value;
redactedArgs[key] = resolved.redacted;
sensitive = sensitive || resolved.sensitive;
}
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function("args", `return (${code})`);
const value = fn(resolvedArgs);
return combineResolved(value, sensitive ? REDACTED : value, sensitive);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new PipelineError("pipeline_js_error", `$js expression failed: ${message}`);
}
}
return combineResolved(expression, expression, false);
}
function resolvePlannedExpression(
expression: PipelineInputExpression,
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
): ResolvedExpression {
if (Array.isArray(expression)) {
const items = expression.map((item) => resolvePlannedExpression(item, pipeline, runtimeInput));
return combineResolved(
items.map((item) => item.value),
items.map((item) => item.redacted),
items.some((item) => item.sensitive),
);
}
if (!isExpressionObject(expression)) {
if (!isRecord(expression))
return combineResolved(expression, expression, false, expression !== undefined);
const redacted: Record<string, unknown> = {};
let sensitive = false;
for (const [key, child] of Object.entries(expression)) {
const resolved = resolvePlannedExpression(child, pipeline, runtimeInput);
redacted[key] = resolved.redacted;
sensitive = sensitive || resolved.sensitive;
}
return combineResolved(redacted, redacted, sensitive);
}
if ("$input" in expression) {
const value = getByJsonPointer(runtimeInput, expression.$input as string);
return combineResolved(value, value, false, value !== undefined);
}
if ("$from" in expression) return combineResolved({ ...expression }, { ...expression }, false);
if ("$env" in expression) {
const value = resolveBinding(pipeline.env, expression.$env as string, false);
return combineResolved(value, value, false, value !== undefined);
}
if ("$secret" in expression) {
resolveBinding(pipeline.secrets, expression.$secret as string, true);
return combineResolved(REDACTED, REDACTED, true, true);
}
if ("$concat" in expression) {
const items = (expression.$concat as PipelineInputExpression[]).map((item) =>
resolvePlannedExpression(item, pipeline, runtimeInput),
);
const hasFrom = items.some((_, i) => {
const raw = (expression.$concat as PipelineInputExpression[])[i];
return isRecord(raw) && "$from" in raw;
});
if (hasFrom)
return combineResolved(
{ ...expression },
{ ...expression },
items.some((item) => item.sensitive),
);
const value = items.map((item) => stringifyConcatValue(item.value)).join("");
const sensitive = items.some((item) => item.sensitive);
const redacted = items
.map((item) => (item.sensitive ? REDACTED : stringifyConcatValue(item.value)))
.join("");
return combineResolved(value, redacted, sensitive);
}
if ("$coalesce" in expression) {
const candidates = expression.$coalesce as PipelineInputExpression[];
const hasFrom = candidates.some((c) => isRecord(c) && "$from" in c);
if (hasFrom) return combineResolved({ ...expression }, { ...expression }, false);
for (const candidate of candidates) {
const resolved = resolvePlannedExpression(candidate, pipeline, runtimeInput);
if (resolved.exists && resolved.value !== undefined && resolved.value !== null) {
return resolved;
}
}
return combineResolved(undefined, undefined, false, false);
}
if ("$js" in expression) {
const argsExpressions = (expression.args ?? {}) as Record<string, PipelineInputExpression>;
const hasFrom = Object.values(argsExpressions).some((v) => isRecord(v) && "$from" in v);
if (hasFrom) return combineResolved({ ...expression }, { ...expression }, false);
const code = expression.$js as string;
const resolvedArgs: Record<string, unknown> = {};
let sensitive = false;
for (const [key, argExpr] of Object.entries(argsExpressions)) {
const resolved = resolvePlannedExpression(argExpr, pipeline, runtimeInput);
resolvedArgs[key] = resolved.value;
sensitive = sensitive || resolved.sensitive;
}
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function("args", `return (${code})`);
const value = fn(resolvedArgs);
return combineResolved(value, sensitive ? REDACTED : value, sensitive);
} catch {
return combineResolved({ ...expression }, { ...expression }, false);
}
}
return combineResolved(expression, expression, false);
}
function resolveBinding(
bindings: Record<string, string | PipelineBinding> | undefined,
name: string,
secret: boolean,
): unknown {
if (!hasBinding(bindings, name)) {
throw new PipelineError(
secret ? "pipeline_secret_error" : "pipeline_env_error",
`${secret ? "Secret" : "Environment"} binding is not declared: ${name}`,
);
}
const binding = bindings![name]!;
const spec =
typeof binding === "string" ? { from: "env" as const, name: binding, required: true } : binding;
const value = spec.from === "env" || spec.from === undefined ? process.env[spec.name] : undefined;
if (value !== undefined && value !== "") return value;
if (spec.default !== undefined) return spec.default;
if (spec.required !== false) {
throw new PipelineError(
secret ? "pipeline_secret_error" : "pipeline_env_error",
`${secret ? "Secret" : "Environment"} binding not found: ${name}`,
);
}
return undefined;
}
function hasBinding(
bindings: Record<string, string | PipelineBinding> | undefined,
name: string,
): boolean {
return !!bindings && Object.prototype.hasOwnProperty.call(bindings, name);
}
function combineResolved(
value: unknown,
redacted: unknown,
sensitive: boolean,
exists = true,
): ResolvedExpression {
return { value, redacted, sensitive, exists };
}
function evaluateConditionWithResolver(
expression: PipelineConditionExpression,
resolveInput: (expression: PipelineInputExpression) => ResolvedExpression,
): boolean {
if (typeof expression === "boolean") return expression;
if (isRecord(expression) && "$exists" in expression) {
try {
return resolveInput(expression.$exists as PipelineInputExpression).exists;
} catch (err) {
if (err instanceof PipelineError && err.code === "pipeline_reference_error") return false;
throw err;
}
}
if (isRecord(expression) && "$and" in expression) {
if (!Array.isArray(expression.$and) || expression.$and.length === 0)
throw conditionError("$and must be a non-empty array");
return (expression.$and as PipelineConditionExpression[]).every((condition) =>
evaluateConditionWithResolver(condition, resolveInput),
);
}
if (isRecord(expression) && "$or" in expression) {
if (!Array.isArray(expression.$or) || expression.$or.length === 0)
throw conditionError("$or must be a non-empty array");
return (expression.$or as PipelineConditionExpression[]).some((condition) =>
evaluateConditionWithResolver(condition, resolveInput),
);
}
if (isRecord(expression) && "$not" in expression) {
return !evaluateConditionWithResolver(
expression.$not as PipelineConditionExpression,
resolveInput,
);
}
const binary = binaryCondition(expression);
if (binary) {
const [leftExpression, rightExpression] = binary.operands;
const left = resolveInput(leftExpression).value;
const right = resolveInput(rightExpression).value;
switch (binary.operator) {
case "$eq":
return jsonEqual(left, right);
case "$ne":
return !jsonEqual(left, right);
case "$gt":
return compareOrdered(left, right, binary.operator) > 0;
case "$gte":
return compareOrdered(left, right, binary.operator) >= 0;
case "$lt":
return compareOrdered(left, right, binary.operator) < 0;
case "$lte":
return compareOrdered(left, right, binary.operator) <= 0;
case "$in":
if (!Array.isArray(right)) throw conditionError("$in right operand must be an array");
return right.some((item) => jsonEqual(item, left));
case "$contains":
if (Array.isArray(left)) return left.some((item) => jsonEqual(item, right));
if (typeof left === "string" && typeof right === "string") return left.includes(right);
throw conditionError("$contains left operand must be an array or string");
}
}
return Boolean(resolveInput(expression as PipelineInputExpression).value);
}
type BinaryConditionOperator =
| "$eq"
| "$ne"
| "$gt"
| "$gte"
| "$lt"
| "$lte"
| "$in"
| "$contains";
function binaryCondition(expression: PipelineConditionExpression):
| {
operator: BinaryConditionOperator;
operands: [PipelineInputExpression, PipelineInputExpression];
}
| undefined {
if (!isRecord(expression)) return undefined;
for (const operator of [
"$eq",
"$ne",
"$gt",
"$gte",
"$lt",
"$lte",
"$in",
"$contains",
] as const) {
if (operator in expression) {
if (!Array.isArray(expression[operator]) || expression[operator].length !== 2) {
throw conditionError(`${operator} must be a two-item array`);
}
return {
operator,
operands: expression[operator] as [PipelineInputExpression, PipelineInputExpression],
};
}
}
return undefined;
}
function jsonEqual(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function compareOrdered(left: unknown, right: unknown, operator: string): number {
if (
typeof left === "number" &&
Number.isFinite(left) &&
typeof right === "number" &&
Number.isFinite(right)
) {
return left - right;
}
if (typeof left === "string" && typeof right === "string") return left.localeCompare(right);
throw conditionError(`${operator} operands must both be finite numbers or both be strings`);
}
function conditionError(message: string): PipelineError {
return new PipelineError("pipeline_condition_error", message);
}
function redactArtifact(artifact: StepArtifact): StepArtifact {
return {
id: artifact.id,
kind: artifact.kind,
mediaType: artifact.mediaType,
metadata: artifact.metadata ? { redacted: true } : undefined,
};
}
function isExpressionObject(value: unknown): value is Record<string, unknown> {
return (
isRecord(value) &&
("$input" in value ||
"$from" in value ||
"$env" in value ||
"$secret" in value ||
"$concat" in value ||
"$coalesce" in value ||
"$js" in value)
);
}
+15
View File
@@ -0,0 +1,15 @@
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
import { registerBlSteps } from "./steps/bl-steps.ts";
import { registerLogicSteps } from "./steps/logic.ts";
import { registerScriptJsStep } from "./steps/script-js.ts";
const initializedDispatchers = new WeakSet<StepDispatcher>();
export function initPipelineSteps(dispatcher = getDefaultStepDispatcher()): StepDispatcher {
if (initializedDispatchers.has(dispatcher)) return dispatcher;
initializedDispatchers.add(dispatcher);
registerBlSteps(dispatcher);
registerLogicSteps(dispatcher);
registerScriptJsStep(dispatcher);
return dispatcher;
}
+81
View File
@@ -0,0 +1,81 @@
import { PipelineError } from "./errors.ts";
import type { PipelineDefinition, PipelinePlanStep, PipelineStepReport } from "./types.ts";
import { collectFromDependencies } from "./utils.ts";
export function buildExecutionPlan(pipeline: PipelineDefinition): PipelinePlanStep[] {
const steps = pipeline.steps.map((step, index) => ({
step,
dependencies: [] as string[],
dependents: [] as string[],
index: index + 1,
total: pipeline.steps.length,
}));
const byId = new Map(steps.map((item) => [item.step.id, item]));
for (const item of steps) {
const dependencies = new Set<string>(item.step.dependsOn ?? []);
collectFromDependencies(item.step.input, dependencies);
if (item.step.when !== undefined) collectFromDependencies(item.step.when, dependencies);
item.dependencies = Array.from(dependencies).sort(
(a, b) =>
pipeline.steps.findIndex((step) => step.id === a) -
pipeline.steps.findIndex((step) => step.id === b),
);
for (const dependency of item.dependencies) byId.get(dependency)?.dependents.push(item.step.id);
}
return steps;
}
export function topologicalOrder(plan: PipelinePlanStep[]): PipelinePlanStep[] {
const remaining = new Set(plan.map((item) => item.step.id));
const done = new Set<string>();
const ordered: PipelinePlanStep[] = [];
while (remaining.size > 0) {
let progressed = false;
for (const item of plan) {
if (!remaining.has(item.step.id)) continue;
if (!item.dependencies.every((dependency) => done.has(dependency))) continue;
ordered.push(item);
done.add(item.step.id);
remaining.delete(item.step.id);
progressed = true;
}
if (!progressed) break;
}
return ordered;
}
export function nextReadySteps(
plan: PipelinePlanStep[],
remaining: Set<string>,
reportByStep: Map<string, PipelineStepReport>,
): PipelinePlanStep[] {
return plan.filter((item) => {
if (!remaining.has(item.step.id)) return false;
return item.dependencies.every((id) => {
const report = reportByStep.get(id);
return (
report?.status === "succeeded" ||
report?.status === "failed" ||
report?.status === "skipped"
);
});
});
}
export function orderReports(
plan: PipelinePlanStep[],
reports: PipelineStepReport[],
): PipelineStepReport[] {
const index = new Map(plan.map((item, order) => [item.step.id, order]));
return [...reports].sort((a, b) => (index.get(a.id) ?? 0) - (index.get(b.id) ?? 0));
}
export function normalizeConcurrency(value: number | undefined): number {
if (value === undefined) return 1;
if (!Number.isInteger(value) || value < 1) {
throw new PipelineError("pipeline_validation_error", "Invalid pipeline execution options", {
details: { issues: ["concurrency must be a positive integer"] },
});
}
return value;
}
+278
View File
@@ -0,0 +1,278 @@
import AjvModule, { type ErrorObject } from "ajv";
import { isRecord } from "./utils.ts";
import type { JsonSchema, JsonSchemaPrimitiveType } from "./types.ts";
interface AjvConstructor {
new (opts?: object): {
validateSchema(schema: object): boolean;
errors?: ErrorObject[] | null;
};
}
const Ajv = AjvModule as unknown as AjvConstructor;
const SUPPORTED_SCHEMA_KEYS = new Set([
"type",
"properties",
"items",
"required",
"enum",
"default",
"description",
"format",
"additionalProperties",
]);
const SUPPORTED_TYPES = new Set<JsonSchemaPrimitiveType>([
"string",
"number",
"integer",
"boolean",
"array",
"object",
"null",
]);
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
strict: false,
useDefaults: false,
});
export function validateJsonSchema(schema: unknown, path = "schema"): string[] {
const issues: string[] = [];
validateJsonSchemaInto(schema, path, issues);
if (issues.length === 0 && !ajv.validateSchema(schema as object)) {
issues.push(...formatAjvErrors(ajv.errors, path));
}
return issues;
}
export function validateInputAgainstSchema(
schema: JsonSchema,
rawInput: Record<string, unknown>,
): { ok: true; value: Record<string, unknown> } | { ok: false; issues: string[] } {
const result = coerceAgainstSchema(rawInput, schema, "input");
if (!result.ok) return { ok: false, issues: result.issues };
return isRecord(result.value)
? { ok: true, value: result.value }
: { ok: false, issues: ["input must be an object"] };
}
export interface JsonPointerParseResult {
ok: boolean;
segments: string[];
issue?: string;
}
export function parseJsonPointer(pointer: string): JsonPointerParseResult {
if (pointer === "") return { ok: true, segments: [] };
if (!pointer.startsWith("/")) return { ok: false, segments: [], issue: "must start with /" };
const segments = pointer.slice(1).split("/");
const decoded: string[] = [];
for (const segment of segments) {
const invalidEscape = segment.match(/~(?![01])/);
if (invalidEscape)
return { ok: false, segments: [], issue: "contains invalid escape sequence" };
decoded.push(segment.replace(/~1/g, "/").replace(/~0/g, "~"));
}
return { ok: true, segments: decoded };
}
export function getByJsonPointer(value: unknown, pointer: string): unknown {
const parsed = parseJsonPointer(pointer);
if (!parsed.ok) return undefined;
let current = value;
for (const segment of parsed.segments) {
if (Array.isArray(current)) {
if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined;
current = current[Number(segment)];
continue;
}
if (isRecord(current)) {
current = current[segment];
continue;
}
return undefined;
}
return current;
}
export function isValidJsonPointer(pointer: string): boolean {
return parseJsonPointer(pointer).ok;
}
function validateJsonSchemaInto(schema: unknown, path: string, issues: string[]): void {
if (!isRecord(schema)) {
issues.push(`${path} must be an object`);
return;
}
for (const key of Object.keys(schema)) {
if (!SUPPORTED_SCHEMA_KEYS.has(key)) issues.push(`${path} has unsupported keyword "${key}"`);
}
if ("type" in schema) validateType(schema.type, `${path}.type`, issues);
if ("properties" in schema) {
if (!isRecord(schema.properties)) {
issues.push(`${path}.properties must be an object`);
} else {
for (const [name, child] of Object.entries(schema.properties)) {
validateJsonSchemaInto(child, `${path}.properties.${name}`, issues);
}
}
}
if ("items" in schema) validateJsonSchemaInto(schema.items, `${path}.items`, issues);
if (
"required" in schema &&
(!Array.isArray(schema.required) || !schema.required.every((item) => typeof item === "string"))
) {
issues.push(`${path}.required must be an array of strings`);
}
if ("enum" in schema && !Array.isArray(schema.enum)) issues.push(`${path}.enum must be an array`);
if ("description" in schema && typeof schema.description !== "string")
issues.push(`${path}.description must be a string`);
if ("format" in schema && typeof schema.format !== "string")
issues.push(`${path}.format must be a string`);
if ("additionalProperties" in schema) {
const additional = schema.additionalProperties;
if (additional !== true && additional !== false && !isRecord(additional)) {
issues.push(`${path}.additionalProperties must be a boolean or schema object`);
} else if (isRecord(additional)) {
validateJsonSchemaInto(additional, `${path}.additionalProperties`, issues);
}
}
}
function formatAjvErrors(errors: ErrorObject[] | null | undefined, path: string): string[] {
if (!errors || errors.length === 0) return [`${path} is invalid`];
return errors.map((error) => {
const location = error.instancePath ? `${path}${error.instancePath.replace(/\//g, ".")}` : path;
const detail = error.message ?? "is invalid";
const keyword = error.keyword ? ` (${error.keyword})` : "";
return `${location} ${detail}${keyword}`;
});
}
function validateType(type: unknown, path: string, issues: string[]): void {
const values = Array.isArray(type) ? type : [type];
if (values.length === 0) {
issues.push(`${path} must not be empty`);
return;
}
for (const value of values) {
if (!SUPPORTED_TYPES.has(value as JsonSchemaPrimitiveType)) {
issues.push(`${path} has unsupported type "${String(value)}"`);
}
}
}
function coerceAgainstSchema(
value: unknown,
schema: JsonSchema,
path: string,
): { ok: true; value: unknown } | { ok: false; issues: string[] } {
if ((value === undefined || value === null || value === "") && schema.default !== undefined) {
value = schema.default;
}
if (schema.enum && value !== undefined) {
const enumStrs = schema.enum.map(stringifyEnumValue);
if (!enumStrs.includes(stringifyEnumValue(value))) {
return { ok: false, issues: [`${path} must be one of: ${enumStrs.join(", ")}`] };
}
}
const types = schemaTypes(schema);
if (types.size === 0) return { ok: true, value };
if (types.size > 1) {
if (Array.isArray(value) && types.has("array")) {
if (!schema.items) return { ok: true, value };
const normalized: unknown[] = [];
const issues: string[] = [];
value.forEach((item, index) => {
const result = coerceAgainstSchema(item, schema.items!, `${path}.${index}`);
if (result.ok) normalized[index] = result.value;
else issues.push(...result.issues);
});
return issues.length > 0 ? { ok: false, issues } : { ok: true, value: normalized };
}
if (isRecord(value) && types.has("object")) {
// fall through to the existing object branch below
} else if (types.has("string") && !Array.isArray(value) && !isRecord(value)) {
return { ok: true, value: String(value) };
}
}
if (types.has("object")) {
if (!isRecord(value)) return { ok: false, issues: [`${path} must be an object`] };
const normalized: Record<string, unknown> = { ...value };
const issues: string[] = [];
for (const name of schema.required ?? []) {
const child = normalized[name];
if (child === undefined || child === null || child === "")
issues.push(`Missing required input "${name}"`);
}
for (const [name, childSchema] of Object.entries(schema.properties ?? {})) {
const child = normalized[name];
if (
(child === undefined || child === null || child === "") &&
childSchema.default === undefined
)
continue;
const result = coerceAgainstSchema(child, childSchema, `${path}.${name}`);
if (result.ok) normalized[name] = result.value;
else issues.push(...result.issues);
}
if (schema.additionalProperties === false) {
for (const key of Object.keys(normalized)) {
if (!schema.properties?.[key]) issues.push(`Unknown input "${key}"`);
}
}
return issues.length > 0 ? { ok: false, issues } : { ok: true, value: normalized };
}
if (types.has("array")) {
if (!Array.isArray(value)) return { ok: false, issues: [`${path} must be an array`] };
if (!schema.items) return { ok: true, value };
const normalized: unknown[] = [];
const issues: string[] = [];
value.forEach((item, index) => {
const result = coerceAgainstSchema(item, schema.items!, `${path}.${index}`);
if (result.ok) normalized[index] = result.value;
else issues.push(...result.issues);
});
return issues.length > 0 ? { ok: false, issues } : { ok: true, value: normalized };
}
if (types.has("string")) return { ok: true, value: String(value) };
if (types.has("number") || types.has("integer")) {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num)) return { ok: false, issues: [`${path} must be a number`] };
if (types.has("integer") && !Number.isInteger(num))
return { ok: false, issues: [`${path} must be an integer`] };
return { ok: true, value: num };
}
if (types.has("boolean")) {
if (typeof value === "boolean") return { ok: true, value };
if (value === "true" || value === "1") return { ok: true, value: true };
if (value === "false" || value === "0") return { ok: true, value: false };
return { ok: false, issues: [`${path} must be a boolean`] };
}
if (types.has("null")) {
return value === null ? { ok: true, value } : { ok: false, issues: [`${path} must be null`] };
}
return { ok: true, value };
}
function schemaTypes(schema: JsonSchema): Set<JsonSchemaPrimitiveType> {
const raw =
schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
return new Set(raw);
}
function stringifyEnumValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value) ?? "";
return String(value as string | number | boolean | bigint | symbol);
}
+711
View File
@@ -0,0 +1,711 @@
/**
* Direct in-process API call implementations for pipeline steps.
* Bypasses the CLI command handler layer and calls requestJson/request directly.
*/
import {
requestJson,
chatEndpoint,
imageEndpoint,
imageSyncEndpoint,
videoGenerateEndpoint,
taskEndpoint,
speechSynthesizeEndpoint,
speechRecognizeEndpoint,
resolveFileUrl,
resolveCredential,
stripUndefined,
type Config,
type ChatRequest,
type ChatResponse,
type DashScopeImageRequest,
type DashScopeImageSyncResponse,
type DashScopeAsyncResponse,
type DashScopeTaskResponse,
type DashScopeVideoRequest,
type DashScopeTTSRequest,
type DashScopeTTSResponse,
type DashScopeASRRequest,
type ChatMessageContent,
isLocalFile,
} from "bailian-cli-core";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { PipelineError } from "../errors.ts";
import type { StepContext } from "../types.ts";
import { resolveImageSize } from "../../utils/image-size.ts";
import { downloadFile } from "../../utils/download.ts";
// --- text/chat ---
export interface TextChatInput {
message?: string;
model?: string;
system?: string;
"max-tokens"?: number;
temperature?: number;
"top-p"?: number;
"enable-thinking"?: boolean;
"thinking-budget"?: number;
}
export async function textChat(
config: Config,
input: TextChatInput,
ctx: StepContext,
): Promise<ChatResponse> {
if (!input.message) {
throw new PipelineError("missing_input", "text/chat requires 'message' input", {
step: "text/chat",
});
}
const model = input.model || "qwen3.7-max";
const messages: Array<{ role: string; content: string }> = [];
if (input.system) messages.push({ role: "system", content: input.system });
messages.push({ role: "user", content: input.message });
const body: ChatRequest = {
model,
messages: messages as ChatRequest["messages"],
max_tokens: input["max-tokens"] ?? 4096,
stream: false,
};
if (input.temperature !== undefined) body.temperature = input.temperature;
if (input["top-p"] !== undefined) body.top_p = input["top-p"];
if (input["enable-thinking"]) {
body.enable_thinking = true;
if (input["thinking-budget"] !== undefined) {
body.thinking_budget = input["thinking-budget"];
}
}
const url = chatEndpoint(config.baseUrl);
const response = await requestJson<ChatResponse>(config, {
url,
method: "POST",
body,
timeout: ctx.blRequestTimeoutSeconds,
signal: ctx.signal,
});
return response;
}
// --- vision/describe ---
export interface VisionDescribeInput {
image?: string | string[];
video?: string;
prompt?: string;
model?: string;
}
export async function visionDescribe(
config: Config,
input: VisionDescribeInput,
ctx: StepContext,
): Promise<ChatResponse> {
const model = input.model || "qwen-vl-max";
const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : [];
const hasVideo = !!input.video;
const defaultPrompt = hasVideo ? "Describe the video." : "Describe the image.";
const prompt = input.prompt || defaultPrompt;
const contentArray: ChatMessageContent[] = [];
// Handle video
if (input.video) {
let videoUrl = input.video;
if (isLocalFile(videoUrl)) {
const credential = await resolveCredential(config);
videoUrl = await resolveFileUrl(videoUrl, credential.token, model, { signal: ctx.signal });
}
contentArray.push({ type: "video_url", video_url: { url: videoUrl } });
}
// Handle images
for (const img of images) {
let imageUrl = img;
if (isLocalFile(img)) {
const credential = await resolveCredential(config);
imageUrl = await resolveFileUrl(img, credential.token, model, { signal: ctx.signal });
}
contentArray.push({ type: "image_url", image_url: { url: imageUrl } });
}
contentArray.push({ type: "text", text: prompt });
const body: ChatRequest = {
model,
messages: [{ role: "user", content: contentArray }],
};
const url = chatEndpoint(config.baseUrl);
return await requestJson<ChatResponse>(config, {
url,
method: "POST",
body,
signal: ctx.signal,
});
}
// --- image/generate ---
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
function isSyncImageModel(model: string): boolean {
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
}
export interface ImageGenerateInput {
prompt?: string;
model?: string;
size?: string;
n?: number;
seed?: number;
"negative-prompt"?: string;
"prompt-extend"?: boolean;
"no-prompt-extend"?: boolean;
watermark?: boolean;
"out-dir"?: string;
"out-prefix"?: string;
}
export async function imageGenerate(
config: Config,
input: ImageGenerateInput,
ctx: StepContext,
): Promise<unknown> {
if (!input.prompt) {
throw new PipelineError("missing_input", "image/generate requires 'prompt' input", {
step: "image/generate",
});
}
const model = input.model || "qwen-image-2.0";
const useSync = isSyncImageModel(model);
const n = input.n ?? 1;
let promptExtend: boolean | undefined;
if (input["no-prompt-extend"]) {
promptExtend = false;
} else if (input["prompt-extend"]) {
promptExtend = true;
} else if (useSync) {
promptExtend = true;
}
const body: DashScopeImageRequest = {
model,
input: {
messages: [{ role: "user", content: [{ text: input.prompt }] }],
},
parameters: {
size: resolveImageSize(input.size, useSync),
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: input.watermark === true ? true : undefined,
negative_prompt: input["negative-prompt"] || undefined,
},
};
if (useSync) {
const url = imageSyncEndpoint(config.baseUrl);
const response = await requestJson<DashScopeImageSyncResponse>(config, {
url,
method: "POST",
body,
signal: ctx.signal,
});
const urls = response.output.choices
.flatMap((c) => c.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) };
} else {
// Async mode: submit then poll
const url = imageEndpoint(config.baseUrl);
const asyncResp = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const result = await pollTask(config, 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;
return result;
}
}
// --- image/edit ---
export interface ImageEditInput {
image?: string | string[];
prompt?: string;
model?: string;
size?: string;
n?: number;
seed?: number;
"negative-prompt"?: string;
"prompt-extend"?: boolean;
"no-prompt-extend"?: boolean;
watermark?: boolean;
"out-dir"?: string;
"out-prefix"?: string;
}
export async function imageEdit(
config: Config,
input: ImageEditInput,
ctx: StepContext,
): Promise<unknown> {
if (!input.prompt) {
throw new PipelineError("missing_input", "image/edit requires 'prompt' input", {
step: "image/edit",
});
}
const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : [];
const model = input.model || "qwen-image-2.0";
const useSync = isSyncImageModel(model);
const n = input.n ?? 1;
let promptExtend: boolean | undefined;
if (input["no-prompt-extend"]) {
promptExtend = false;
} else if (input["prompt-extend"]) {
promptExtend = true;
} else if (useSync) {
promptExtend = true;
}
const content: Array<{ text?: string; image?: string }> = [];
for (const img of images) {
let imageUrl = img;
if (isLocalFile(img)) {
const credential = await resolveCredential(config);
imageUrl = await resolveFileUrl(img, credential.token, model, { signal: ctx.signal });
}
content.push({ image: imageUrl });
}
content.push({ text: input.prompt });
const body: DashScopeImageRequest = {
model,
input: {
messages: [{ role: "user", content }],
},
parameters: {
size: resolveImageSize(input.size, useSync),
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: input.watermark === true ? true : undefined,
negative_prompt: input["negative-prompt"] || undefined,
},
};
if (useSync) {
const url = imageSyncEndpoint(config.baseUrl);
const response = await requestJson<DashScopeImageSyncResponse>(config, {
url,
method: "POST",
body,
signal: ctx.signal,
});
const urls = response.output.choices
.flatMap((c) => c.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]);
return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) };
} else {
const url = imageEndpoint(config.baseUrl);
const asyncResp = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const result = await pollTask(config, 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;
return result;
}
}
/**
* If `outDir` is provided, download each URL to disk under `outDir/`.
* Naming: single image -> `<prefix>.png`; multiple -> `<prefix>_001.png`, ...
* Default prefix is `image`.
*/
async function maybeDownloadImages(
urls: string[],
outDir: string | undefined,
outPrefix: string | undefined,
): Promise<string[] | undefined> {
if (!outDir || urls.length === 0) return undefined;
await mkdir(outDir, { recursive: true });
const prefix = outPrefix && outPrefix.length > 0 ? outPrefix : "image";
const items =
urls.length === 1
? [{ url: urls[0], destPath: join(outDir, `${prefix}.png`) }]
: urls.map((url, i) => ({
url,
destPath: join(outDir, `${prefix}_${String(i + 1).padStart(3, "0")}.png`),
}));
const saved: string[] = [];
for (const { url, destPath } of items) {
await downloadFile(url, destPath, { quiet: true });
saved.push(destPath);
}
return saved;
}
// --- video/generate (async) ---
export interface VideoGenerateInput {
prompt?: string;
model?: string;
image?: string;
"negative-prompt"?: string;
resolution?: string;
ratio?: string;
duration?: number;
"prompt-extend"?: boolean;
watermark?: boolean;
seed?: number;
"poll-interval"?: number;
}
export async function videoGenerate(
config: Config,
input: VideoGenerateInput,
ctx: StepContext,
): Promise<unknown> {
if (!input.prompt) {
throw new PipelineError("missing_input", "video/generate requires 'prompt' input", {
step: "video/generate",
});
}
const model = input.model || (input.image ? "happyhorse-1.0-i2v" : "happyhorse-1.0-t2v");
let resolvedImageUrl: string | undefined;
if (input.image) {
if (isLocalFile(input.image)) {
const credential = await resolveCredential(config);
resolvedImageUrl = await resolveFileUrl(input.image, credential.token, model, {
signal: ctx.signal,
});
} else {
resolvedImageUrl = input.image;
}
}
const body: DashScopeVideoRequest = {
model,
input: {
prompt: input.prompt,
negative_prompt: input["negative-prompt"] || undefined,
...(resolvedImageUrl
? { media: [{ type: "first_frame" as const, url: resolvedImageUrl }] }
: {}),
},
parameters: {
resolution: input.resolution || undefined,
ratio: input.ratio || undefined,
duration: input.duration,
prompt_extend: input["prompt-extend"],
watermark: input.watermark,
seed: input.seed,
},
};
stripUndefined(body.parameters as Record<string, unknown>);
const url = videoGenerateEndpoint(config.baseUrl);
const asyncResp = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const pollIntervalMs = (input["poll-interval"] ?? 10) * 1000;
const timeoutMs = (ctx.timeoutSeconds ?? 900) * 1000;
return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx);
}
// --- speech/synthesize ---
export interface SpeechSynthesizeInput {
text?: string;
"text-file"?: string;
model?: string;
voice?: string;
format?: string;
"sample-rate"?: number;
volume?: number;
rate?: number;
pitch?: number;
seed?: number;
language?: string;
instruction?: string;
"enable-ssml"?: boolean;
out?: string;
}
export async function speechSynthesize(
config: Config,
input: SpeechSynthesizeInput,
ctx: StepContext,
): Promise<unknown> {
let text = input.text;
if (!text && input["text-file"]) {
const { readFileSync } = await import("node:fs");
text = readFileSync(input["text-file"], "utf-8").trim();
}
if (!text) {
throw new PipelineError("missing_input", "speech/synthesize requires 'text' input", {
step: "speech/synthesize",
});
}
const model = input.model || "cosyvoice-v3-flash";
const body: DashScopeTTSRequest = {
model,
input: {
text,
voice: input.voice,
format: input.format as DashScopeTTSRequest["input"]["format"],
sample_rate: input["sample-rate"],
volume: input.volume,
rate: input.rate,
pitch: input.pitch,
seed: input.seed,
language_hints: input.language ? [input.language] : undefined,
instruction: input.instruction,
enable_ssml: input["enable-ssml"],
},
};
stripUndefined(body.input as Record<string, unknown>);
const url = speechSynthesizeEndpoint(config.baseUrl);
const response = await requestJson<DashScopeTTSResponse>(config, {
url,
method: "POST",
body,
signal: ctx.signal,
});
return {
audio_url: response.output.audio.url,
url_expires_at: response.output.audio.expires_at,
model,
voice: input.voice,
request_id: response.request_id,
};
}
// --- speech/recognize ---
export interface SpeechRecognizeInput {
url?: string | string[];
model?: string;
language?: string;
diarization?: boolean;
"speaker-count"?: number;
"vocabulary-id"?: string;
"channel-id"?: number;
"poll-interval"?: number;
}
export async function speechRecognize(
config: Config,
input: SpeechRecognizeInput,
ctx: StepContext,
): Promise<unknown> {
const rawUrls = Array.isArray(input.url) ? input.url : input.url ? [input.url] : [];
if (rawUrls.length === 0) {
throw new PipelineError("missing_input", "speech/recognize requires 'url' input", {
step: "speech/recognize",
});
}
// Resolve local files to upload URLs
const fileUrls: string[] = [];
for (const u of rawUrls) {
if (isLocalFile(u)) {
const credential = await resolveCredential(config);
fileUrls.push(
await resolveFileUrl(u, credential.token, input.model || "fun-asr", {
signal: ctx.signal,
}),
);
} else {
fileUrls.push(u);
}
}
const model = input.model || "fun-asr";
const body: DashScopeASRRequest = {
model,
input: { file_urls: fileUrls },
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
language_hints: input.language ? [input.language] : undefined,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
},
};
stripUndefined(body.parameters as Record<string, unknown>);
const url = speechRecognizeEndpoint(config.baseUrl);
const asyncResp = await requestJson<DashScopeAsyncResponse>(config, {
url,
method: "POST",
body,
async: true,
signal: ctx.signal,
});
const taskId = asyncResp.output.task_id;
const pollIntervalMs = (input["poll-interval"] ?? 2) * 1000;
const timeoutMs = (ctx.timeoutSeconds ?? 300) * 1000;
return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx);
}
// --- Shared: task polling ---
/**
* Flatten DashScopeTaskResponse into a top-level object for artifact extraction.
* The CLI `--output json` used to emit output fields at the top level;
* we replicate that shape so artifactsFromBlData works correctly.
*/
function flattenTaskResponse(resp: DashScopeTaskResponse): Record<string, unknown> {
const { output, request_id, usage } = resp;
const flat: Record<string, unknown> = {
task_id: output.task_id,
task_status: output.task_status,
request_id,
};
if (output.video_url) flat.video_url = output.video_url;
if (output.choices) {
const urls = output.choices
.flatMap((c) => c.message?.content || [])
.map((item) => item.image)
.filter(Boolean);
if (urls.length > 0) flat.urls = urls;
}
if (output.results) {
const urls = output.results.map((r) => r.url).filter(Boolean);
if (urls.length > 0 && !flat.urls) flat.urls = urls;
}
if (output.task_metrics) flat.task_metrics = output.task_metrics;
if (usage) flat.usage = usage;
return flat;
}
async function pollTask(
config: Config,
taskId: string,
ctx?: StepContext,
): Promise<Record<string, unknown>> {
const pollIntervalMs = 3000;
const timeoutMs = config.timeout * 1000;
return await pollTaskWithOptions(config, taskId, pollIntervalMs, timeoutMs, ctx);
}
async function pollTaskWithOptions(
config: Config,
taskId: string,
pollIntervalMs: number,
timeoutMs: number,
ctx?: StepContext,
): Promise<Record<string, unknown>> {
const started = Date.now();
let attempt = 0;
while (true) {
if (ctx?.signal?.aborted) {
throw new PipelineError("aborted", "Task polling was aborted", {
details: { taskId },
});
}
await delay(pollIntervalMs, ctx?.signal);
attempt++;
const url = taskEndpoint(config.baseUrl, taskId);
const result = await requestJson<DashScopeTaskResponse>(config, {
url,
method: "GET",
signal: ctx?.signal,
});
const status = result.output.task_status;
if (status === "SUCCEEDED") {
return flattenTaskResponse(result);
}
if (status === "FAILED") {
const msg = result.output.message || result.output.code || "Task failed";
throw new PipelineError("async_task_failed", msg, {
details: { taskId, data: result },
});
}
const elapsedMs = Date.now() - started;
await ctx?.emitEvent?.({
type: "step.polling",
timestamp: new Date().toISOString(),
status: "running",
taskId,
taskStatus: status,
elapsedMs,
pollAttempt: attempt,
});
if (elapsedMs > timeoutMs) {
throw new PipelineError(
"async_poll_timeout",
`Task ${taskId} timed out after ${Math.round(timeoutMs / 1000)}s`,
{ details: { taskId, timeoutMs, pollAttempts: attempt } },
);
}
}
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
resolve();
}, ms);
const abort = () => {
cleanup();
reject(new PipelineError("aborted", "Poll delay was aborted"));
};
const cleanup = () => {
clearTimeout(timeout);
signal.removeEventListener("abort", abort);
};
if (signal.aborted) abort();
else signal.addEventListener("abort", abort, { once: true });
});
}
+306
View File
@@ -0,0 +1,306 @@
import { registerStep } from "../dispatcher.ts";
import { buildPipelineConfig } from "../bl-config.ts";
import { isRecord } from "../utils.ts";
import {
textChat,
visionDescribe,
imageGenerate,
imageEdit,
videoGenerate,
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,
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),
};
// Build result with artifact extraction from the raw API data
const RESULT_BUILDERS: Record<string, (data: unknown) => StepResult> = {
"image/generate": (data) => ({
data,
artifacts: artifactsFromBlData(data),
warnings: [],
metadata: {},
}),
"image/edit": (data) => ({
data,
artifacts: artifactsFromBlData(data),
warnings: [],
metadata: {},
}),
"video/generate": (data) => ({
data,
artifacts: artifactsFromBlData(data),
warnings: [],
metadata: {},
}),
"speech/synthesize": (data) => ({
data,
artifacts: speechArtifactsFromBlData(data, "audio"),
warnings: [],
metadata: {},
}),
"speech/recognize": (data) => ({
data,
artifacts: speechArtifactsFromBlData(data, "transcript"),
warnings: [],
metadata: {},
}),
};
// --- Output schemas ---
const OUTPUT_SCHEMAS: Record<string, StepOutputSchema> = {
"text/chat": {
description: "LLM text completion via chat API",
paths: [
{ path: "/data/choices/0/message/content", description: "Generated text content" },
{ path: "/data/choices/0/message/role", description: "Message role (assistant)" },
{ path: "/data/usage/total_tokens", description: "Total token usage" },
],
},
"vision/describe": {
description: "Multimodal vision understanding (image/video to text)",
paths: [
{ path: "/data/choices/0/message/content", description: "Generated description text" },
{ path: "/data/choices/0/message/role", description: "Message role (assistant)" },
{ path: "/data/usage/total_tokens", description: "Total token usage" },
],
},
"image/generate": {
description: "Text-to-image generation",
paths: [
{ path: "/data/urls", description: "Array of generated image URLs" },
{ path: "/data/urls/0", description: "First generated image URL" },
{ path: "/data/task_id", description: "Async task ID (if applicable)" },
{ path: "/artifacts/0/url", description: "First artifact URL" },
{ path: "/artifacts/0/path", description: "First artifact local path (if saved)" },
],
},
"image/edit": {
description: "Image editing with reference image(s)",
paths: [
{ path: "/data/urls", description: "Array of generated image URLs" },
{ path: "/data/urls/0", description: "First generated image URL" },
{ path: "/data/task_id", description: "Async task ID (if applicable)" },
{ path: "/artifacts/0/url", description: "First artifact URL" },
{ path: "/artifacts/0/path", description: "First artifact local path (if saved)" },
],
},
"video/generate": {
description: "Video generation (text-to-video or image-to-video)",
paths: [
{ path: "/data/video_url", description: "Generated video URL" },
{ path: "/data/task_id", description: "Async task ID" },
{ path: "/artifacts/0/url", description: "First artifact URL" },
{ path: "/artifacts/0/path", description: "First artifact local path (if saved)" },
],
},
"speech/synthesize": {
description: "Text-to-speech synthesis",
paths: [
{ path: "/data/audio_url", description: "Generated audio URL" },
{ path: "/data/url_expires_at", description: "Audio URL expiration time" },
{ path: "/artifacts/0/url", description: "First artifact URL" },
],
},
"speech/recognize": {
description: "Speech recognition (ASR)",
paths: [
{ path: "/data/text", description: "Full transcribed text" },
{ path: "/data/task_id", description: "Async task ID" },
{ path: "/artifacts/0/taskId", description: "Task ID from artifact" },
],
},
};
// --- Registration ---
export function registerBlSteps(dispatcher?: StepDispatcher): void {
for (const id of Object.keys(DIRECT_API_HANDLERS)) {
registerStep(
id,
async (input, ctx) => {
return await executeDirectBlStep(id, input, ctx);
},
dispatcher,
OUTPUT_SCHEMAS[id],
);
}
}
// --- Execution ---
async function executeDirectBlStep(
id: string,
input: Record<string, unknown>,
ctx: StepContext,
): Promise<StepResult> {
if (ctx.dryRun) {
return {
metadata: { dryRun: true, step: id, plannedInput: input },
warnings: [
{ code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` },
],
};
}
const handler = DIRECT_API_HANDLERS[id];
if (!handler) {
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 builder = RESULT_BUILDERS[id];
if (builder) {
return builder(data);
}
return { data, warnings: [], metadata: {} };
}
// --- Artifact extraction helpers ---
function artifactsFromBlData(data: unknown): StepArtifact[] | undefined {
if (!isRecord(data)) return undefined;
const artifacts: StepArtifact[] = [];
const taskId = stringValue(data.task_id) ?? stringValue(data.taskId);
for (const url of stringArray(data.urls)) {
artifacts.push({ url, taskId, kind: mediaKindFromUrl(url) });
}
for (const path of stringArray(data.saved)) {
artifacts.push({ path, taskId, kind: mediaKindFromPath(path) });
}
const videoUrl = stringValue(data.video_url) ?? stringValue(data.videoUrl);
if (videoUrl) {
artifacts.push({ url: videoUrl, taskId, kind: "video" });
}
const savedPath = stringValue(data.saved);
if (savedPath) {
artifacts.push({ path: savedPath, taskId, kind: mediaKindFromPath(savedPath) });
}
if (Array.isArray(data.videos)) {
for (const item of data.videos) {
if (!isRecord(item)) continue;
const itemTaskId = stringValue(item.task_id) ?? stringValue(item.taskId);
const itemUrl = stringValue(item.video_url) ?? stringValue(item.videoUrl);
const itemPath = stringValue(item.saved);
if (itemUrl) artifacts.push({ url: itemUrl, taskId: itemTaskId, kind: "video" });
if (itemPath) artifacts.push({ path: itemPath, taskId: itemTaskId, kind: "video" });
}
}
const taskIds = Array.isArray(data.task_ids)
? data.task_ids.map(stringValue).filter(isString)
: [];
for (const id of taskIds) {
artifacts.push({ taskId: id, kind: "task" });
}
if (artifacts.length === 0 && taskId) {
artifacts.push({ taskId, kind: "task" });
}
return artifacts.length > 0 ? artifacts : undefined;
}
function speechArtifactsFromBlData(
data: unknown,
defaultKind: "audio" | "transcript",
): StepArtifact[] | undefined {
if (!isRecord(data)) return undefined;
const artifacts: StepArtifact[] = [];
const taskId = stringValue(data.task_id) ?? stringValue(data.taskId);
const urls = [
stringValue(data.url),
stringValue(data.audio_url),
stringValue(data.audioUrl),
stringValue(data.output_url),
stringValue(data.outputUrl),
].filter(isString);
for (const url of urls) {
artifacts.push({ url, taskId, kind: defaultKind });
}
const paths = [
stringValue(data.saved),
stringValue(data.path),
stringValue(data.output_path),
stringValue(data.outputPath),
].filter(isString);
for (const path of paths) {
artifacts.push({ path, taskId, kind: defaultKind });
}
if (artifacts.length === 0 && taskId) {
artifacts.push({ taskId, kind: "task" });
}
return artifacts.length > 0 ? uniqueArtifacts(artifacts) : undefined;
}
function uniqueArtifacts(artifacts: StepArtifact[]): StepArtifact[] {
const seen = new Set<string>();
const unique: StepArtifact[] = [];
for (const artifact of artifacts) {
const key = `${artifact.kind ?? ""}:${artifact.taskId ?? ""}:${artifact.url ?? ""}:${artifact.path ?? ""}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(artifact);
}
return unique;
}
function isString(value: unknown): value is string {
return typeof value === "string";
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(stringValue).filter(isString) : [];
}
function mediaKindFromUrl(url: string): string {
try {
return mediaKindFromPath(new URL(url).pathname);
} catch {
return "artifact";
}
}
function mediaKindFromPath(path: string): string {
const lower = path.toLowerCase();
if (lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".webm")) return "video";
if (
lower.endsWith(".png") ||
lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") ||
lower.endsWith(".webp")
)
return "image";
return "artifact";
}
+156
View File
@@ -0,0 +1,156 @@
import { PipelineError } from "../errors.ts";
import { registerStep } from "../dispatcher.ts";
import { isRecord } from "../utils.ts";
import type { StepDispatcher } from "../dispatcher.ts";
import type { StepOutputSchema, StepResult, PipelineConditionExpression } from "../types.ts";
import { evaluateResolvedCondition } from "../expressions.ts";
const SWITCH_OUTPUT_SCHEMA: StepOutputSchema = {
description: "Conditional branch selection from a list of cases",
paths: [
{ path: "/data/selected", description: "Key of the matched case" },
{ path: "/data/value", description: "Value of the matched case" },
{ path: "/data/matched", description: "Whether a case was matched (boolean)" },
],
};
const SELECT_OUTPUT_SCHEMA: StepOutputSchema = {
description: "Select first available value from candidates",
paths: [
{ path: "/data/source", description: "Source identifier of the selected candidate" },
{ path: "/data/value", description: "Value of the selected candidate" },
{ path: "/data/matched", description: "Whether a candidate was matched (boolean)" },
],
};
const ASSERT_OUTPUT_SCHEMA: StepOutputSchema = {
description: "Assert a condition is true",
paths: [
{ path: "/data/ok", description: "Whether the assertion passed (boolean)" },
{ path: "/data/message", description: "Assertion message" },
],
};
export function registerLogicSteps(dispatcher?: StepDispatcher): void {
registerStep(
"logic/switch",
(_input) => {
const cases = asSwitchCases(_input.cases);
for (const entry of cases) {
if (evaluateResolvedCondition(entry.condition)) {
return { data: { selected: entry.key, value: entry.value, matched: true } };
}
}
if ("defaultValue" in _input) {
return {
data: {
selected: typeof _input.defaultKey === "string" ? _input.defaultKey : "default",
value: _input.defaultValue,
matched: false,
},
};
}
throw new PipelineError("logic_no_match", "logic/switch did not match any case", {
step: "logic/switch",
details: { caseCount: cases.length },
});
},
dispatcher,
SWITCH_OUTPUT_SCHEMA,
);
registerStep(
"logic/select",
(_input) => {
const candidates = asSelectCandidates(_input.candidates);
for (const candidate of candidates) {
if (candidate.available !== undefined && !evaluateResolvedCondition(candidate.available))
continue;
if (candidate.value !== undefined && candidate.value !== null) {
return { data: { source: candidate.source, value: candidate.value, matched: true } };
}
}
if ("defaultValue" in _input) {
return {
data: {
source: typeof _input.defaultSource === "string" ? _input.defaultSource : "default",
value: _input.defaultValue,
matched: false,
},
};
}
throw new PipelineError(
"logic_no_candidate",
"logic/select did not receive an available candidate",
{ step: "logic/select", details: { candidateCount: candidates.length } },
);
},
dispatcher,
SELECT_OUTPUT_SCHEMA,
);
registerStep(
"logic/assert",
(_input): StepResult => {
const message =
typeof _input.message === "string" ? _input.message : "Logic assertion failed";
const metadata = isRecord(_input.metadata) ? _input.metadata : undefined;
if (!evaluateResolvedCondition(asCondition(_input.condition))) {
if (_input.soft === true) {
return {
data: { ok: false, message, ...(metadata ? { metadata } : {}) },
};
}
throw new PipelineError("logic_assertion_failed", message, {
step: "logic/assert",
details: metadata ?? {},
});
}
return {
data: {
ok: true,
...(_input.message !== undefined ? { message } : {}),
...(metadata ? { metadata } : {}),
},
};
},
dispatcher,
ASSERT_OUTPUT_SCHEMA,
);
}
interface LogicSwitchCase {
key: string;
condition: PipelineConditionExpression;
value: unknown;
}
interface LogicSelectCandidate {
source: string;
value: unknown;
available?: PipelineConditionExpression;
}
function asSwitchCases(value: unknown): LogicSwitchCase[] {
if (!Array.isArray(value))
throw new PipelineError("logic_input_error", "cases must be an array", {
step: "logic/switch",
});
return value as LogicSwitchCase[];
}
function asSelectCandidates(value: unknown): LogicSelectCandidate[] {
if (!Array.isArray(value))
throw new PipelineError("logic_input_error", "candidates must be an array", {
step: "logic/select",
});
return value as LogicSelectCandidate[];
}
function asCondition(value: unknown): PipelineConditionExpression {
if (value === undefined)
throw new PipelineError("logic_input_error", "condition is required", {
step: "logic/assert",
});
return value as PipelineConditionExpression;
}
@@ -0,0 +1,37 @@
import { PipelineError } from "../errors.ts";
import { registerStep } from "../dispatcher.ts";
import type { StepDispatcher } from "../dispatcher.ts";
import type { StepOutputSchema } from "../types.ts";
const SCRIPT_JS_OUTPUT_SCHEMA: StepOutputSchema = {
description: "Execute inline JavaScript; output shape is user-defined by the return value",
paths: [{ path: "/data", description: "Return value of the script (user-defined structure)" }],
};
export function registerScriptJsStep(dispatcher?: StepDispatcher): void {
registerStep(
"script/js",
(_input) => {
const code = _input.code as string;
if (!code || typeof code !== "string") {
throw new PipelineError("script_js_error", "script/js requires a 'code' string input", {
step: "script/js",
});
}
const args = (_input.args ?? {}) as Record<string, unknown>;
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function("args", code);
const result = fn(args);
return { data: result ?? {} };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new PipelineError("script_js_error", `script/js execution failed: ${message}`, {
step: "script/js",
});
}
},
dispatcher,
SCRIPT_JS_OUTPUT_SCHEMA,
);
}
+341
View File
@@ -0,0 +1,341 @@
// Pipeline types — minimal extraction from packages/pipeline
import type { StepDispatcher } from "./dispatcher.ts";
export const WORKFLOW_VERSION = "workflow/v1";
// --- Step result types (replacing AdapterResult) ---
export interface StepArtifact {
id?: string;
path?: string;
url?: string;
mediaType?: string;
taskId?: string;
kind?: string;
expiresAt?: string;
metadata?: Record<string, unknown>;
}
export interface StepWarning {
code: string;
message: string;
details?: Record<string, unknown>;
}
export interface StepResult {
data?: unknown;
artifacts?: StepArtifact[];
warnings?: StepWarning[];
metadata?: Record<string, unknown>;
}
export interface StructuredStepErrorShape {
code: string;
message: string;
step?: string;
details?: Record<string, unknown>;
}
// --- JSON Schema types ---
export type JsonSchemaPrimitiveType =
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
export interface JsonSchema {
type?: JsonSchemaPrimitiveType | JsonSchemaPrimitiveType[];
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
required?: string[];
enum?: unknown[];
default?: unknown;
description?: string;
format?: string;
additionalProperties?: boolean | JsonSchema;
}
// --- Pipeline definition types ---
export type PipelineInputExpression =
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
| unknown
| { $input: string }
| { $from: string; path?: string }
| { $env: string }
| { $secret: string }
| { $concat: PipelineInputExpression[] }
| { $coalesce: PipelineInputExpression[] }
| { $js: string; args?: Record<string, PipelineInputExpression> };
export type PipelineConditionExpression =
| boolean
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
| PipelineInputExpression
| { $exists: PipelineInputExpression }
| { $eq: [PipelineInputExpression, PipelineInputExpression] }
| { $ne: [PipelineInputExpression, PipelineInputExpression] }
| { $gt: [PipelineInputExpression, PipelineInputExpression] }
| { $gte: [PipelineInputExpression, PipelineInputExpression] }
| { $lt: [PipelineInputExpression, PipelineInputExpression] }
| { $lte: [PipelineInputExpression, PipelineInputExpression] }
| { $in: [PipelineInputExpression, PipelineInputExpression] }
| { $contains: [PipelineInputExpression, PipelineInputExpression] }
| { $and: PipelineConditionExpression[] }
| { $or: PipelineConditionExpression[] }
| { $not: PipelineConditionExpression };
export interface PipelineRetryPolicy {
maxAttempts?: number;
backoff?: "none" | "linear" | "exponential";
}
export interface PipelineBinding {
from?: "env";
name: string;
required?: boolean;
default?: unknown;
}
export interface PipelineStep {
id: string;
type: string;
input: Record<string, PipelineInputExpression>;
dependsOn?: string[];
when?: PipelineConditionExpression;
retry?: PipelineRetryPolicy;
timeout?: string | number;
}
export interface PipelineDefinition {
version: typeof WORKFLOW_VERSION;
inputs?: JsonSchema;
env?: Record<string, string | PipelineBinding>;
secrets?: Record<string, string | PipelineBinding>;
steps: PipelineStep[];
}
// --- Execution report types ---
export interface PipelineStepReport {
id: string;
type: string;
status: "planned" | "succeeded" | "failed" | "skipped";
dependencies?: string[];
input?: Record<string, unknown>;
output?: StepResult;
error?: StructuredStepErrorShape;
startedAt?: string;
finishedAt?: string;
attempts?: number;
skipReason?: string;
condition?: "pending" | "true" | "false";
}
export interface PipelineExecutionReport {
status: "planned" | "succeeded" | "failed";
version: typeof WORKFLOW_VERSION;
steps: PipelineStepReport[];
artifacts: StepArtifact[];
}
// --- Lifecycle event types ---
export interface PipelineEventStep {
id: string;
type: string;
dependencies?: string[];
index?: number;
total?: number;
}
export interface PipelineEventTiming {
startedAt?: string;
finishedAt?: string;
durationMs?: number;
}
export interface PipelineEventInputSummary {
keys: string[];
redactedKeys?: string[];
}
export interface PipelineEventOutputSummary {
dataType?: string;
artifactCount: number;
warningCount: number;
metadata?: Record<string, unknown>;
}
export type PipelineLifecycleEvent =
| {
type: "pipeline.started";
timestamp: string;
status: "running" | "planned";
stepCount: number;
dryRun: boolean;
}
| {
type: "step.input.resolved";
timestamp: string;
status: "running" | "planned";
step: PipelineEventStep;
input: PipelineEventInputSummary;
}
| {
type: "step.planned";
timestamp: string;
status: "planned";
step: PipelineEventStep;
input: PipelineEventInputSummary;
condition?: "pending";
}
| {
type: "step.started";
timestamp: string;
status: "running";
step: PipelineEventStep;
timing: PipelineEventTiming;
attempt: number;
}
| {
type: "step.retrying";
timestamp: string;
status: "running";
step: PipelineEventStep;
attempt: number;
nextAttempt: number;
error: StructuredStepErrorShape;
}
| {
type: "artifact.created";
timestamp: string;
status: "running";
step: PipelineEventStep;
artifact: StepArtifact;
}
| {
type: "step.succeeded";
timestamp: string;
status: "running";
step: PipelineEventStep;
timing: PipelineEventTiming;
output: PipelineEventOutputSummary;
attempt: number;
warnings?: StepWarning[];
}
| {
type: "step.skipped";
timestamp: string;
status: "running" | "planned";
step: PipelineEventStep;
reason: string;
}
| {
type: "step.failed";
timestamp: string;
status: "failed";
step: PipelineEventStep;
timing: PipelineEventTiming;
attempt: number;
error: StructuredStepErrorShape;
}
| {
type: "pipeline.planned";
timestamp: string;
status: "planned";
stepCount: number;
artifactCount: number;
}
| {
type: "pipeline.succeeded";
timestamp: string;
status: "succeeded";
stepCount: number;
artifactCount: number;
}
| {
type: "step.polling";
timestamp: string;
status: "running";
step: PipelineEventStep;
taskId: string;
taskStatus: string;
elapsedMs: number;
pollAttempt: number;
}
| {
type: "pipeline.failed";
timestamp: string;
status: "failed";
stepCount: number;
artifactCount: number;
failedStep: PipelineEventStep;
error: StructuredStepErrorShape;
};
export type PipelineEventHandler = (event: PipelineLifecycleEvent) => void | Promise<void>;
// --- Execution options ---
export interface ExecutePipelineOptions {
onEvent?: PipelineEventHandler;
concurrency?: number;
retryDelayBaseMs?: number;
sleep?: (ms: number) => Promise<void>;
basePath?: string;
dryRun?: boolean;
signal?: AbortSignal;
timeoutSeconds?: number;
blRequestTimeoutSeconds?: number;
stepDispatcher?: StepDispatcher;
}
// --- Scheduler types ---
export interface PipelinePlanStep {
step: PipelineStep;
dependencies: string[];
dependents: string[];
index: number;
total: number;
}
export type ResolvedExpression = {
value: unknown;
redacted: unknown;
sensitive: boolean;
exists: boolean;
};
// --- Step output schema types ---
export interface StepOutputPath {
path: string;
description: string;
}
export interface StepOutputSchema {
description?: string;
paths: StepOutputPath[];
}
// --- Step handler types ---
export type StepHandler = (
input: Record<string, unknown>,
ctx: StepContext,
) => Promise<StepResult> | StepResult;
export interface StepContext {
dryRun: boolean;
signal?: AbortSignal;
timeoutSeconds?: number;
blRequestTimeoutSeconds?: number;
emitEvent?: (event: Record<string, unknown>) => void | Promise<void>;
blConfig?: unknown;
}
+104
View File
@@ -0,0 +1,104 @@
import { access } from "node:fs/promises";
import { resolve } from "node:path";
import type { PipelineStep } from "./types.ts";
/** Keys that represent output destination paths (not input files to be read). */
export const OUTPUT_PATH_KEYS = ["download", "out-dir", "output"];
function isRelativePath(value: string): boolean {
return value.startsWith("./") || value.startsWith("../");
}
export interface ResolvedInputPaths {
input: Record<string, unknown>;
resolvedKeys: string[];
}
export function resolveInputPaths(
input: Record<string, unknown>,
basePath: string,
): ResolvedInputPaths {
const result: Record<string, unknown> = {};
const resolvedKeys: string[] = [];
for (const [key, value] of Object.entries(input)) {
if (typeof value === "string" && isRelativePath(value)) {
result[key] = resolve(basePath, value);
resolvedKeys.push(key);
} else {
result[key] = value;
}
}
return { input: result, resolvedKeys };
}
export async function checkInputPaths(
input: Record<string, unknown>,
keys: string[],
stepId: string,
): Promise<string[]> {
const issues: string[] = [];
for (const key of keys) {
const value = input[key];
if (typeof value !== "string") continue;
const exists = await access(value).then(
() => true,
() => false,
);
if (!exists) {
issues.push(`Step "${stepId}" input "${key}" references file that does not exist: ${value}`);
}
}
return issues;
}
export function parseTimeoutSeconds(value: string | number | undefined): number | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : undefined;
const match = value.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
if (!match) return undefined;
const amount = Number(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return undefined;
const unit = match[2] ?? "s";
if (unit === "ms") return amount / 1000;
if (unit === "m") return amount * 60;
return amount;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
export function collectFromDependencies(value: unknown, dependencies: Set<string>): void {
if (Array.isArray(value)) {
value.forEach((item) => collectFromDependencies(item, dependencies));
return;
}
if (!isRecord(value)) return;
if ("$from" in value && typeof value.$from === "string") dependencies.add(value.$from);
for (const child of Object.values(value)) collectFromDependencies(child, dependencies);
}
export async function preflightCheckInputFiles(
steps: PipelineStep[],
basePath: string,
): Promise<string[]> {
const issues: string[] = [];
for (const step of steps) {
for (const [key, value] of Object.entries(step.input)) {
if (OUTPUT_PATH_KEYS.includes(key)) continue;
if (typeof value !== "string") continue;
if (!isRelativePath(value)) continue;
const absPath = resolve(basePath, value);
const exists = await access(absPath).then(
() => true,
() => false,
);
if (!exists) {
issues.push(
`Step "${step.id}" input "${key}" references file that does not exist: ${absPath}`,
);
}
}
}
return issues;
}
+262
View File
@@ -0,0 +1,262 @@
import { PipelineError } from "./errors.ts";
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
import { validateInputAgainstSchema } from "./schema.ts";
import { validateJsonSchema } from "./schema.ts";
import type { PipelineDefinition } from "./types.ts";
import { WORKFLOW_VERSION } from "./types.ts";
import { collectFromDependencies, isRecord, parseTimeoutSeconds } from "./utils.ts";
export function collectPipelineIssues(
pipeline: PipelineDefinition,
dispatcher: StepDispatcher = getDefaultStepDispatcher(),
): string[] {
const structuralIssues = validateWorkflowStructure(pipeline);
if (structuralIssues.length > 0) return structuralIssues;
return collectPipelineSemanticIssues(pipeline, dispatcher);
}
/**
* Collect non-blocking hints about potentially invalid $from paths.
* These do not prevent execution but help workflow authors catch typos.
*/
export function collectPipelineHints(
pipeline: PipelineDefinition,
dispatcher: StepDispatcher = getDefaultStepDispatcher(),
): string[] {
const structuralIssues = validateWorkflowStructure(pipeline);
if (structuralIssues.length > 0) return [];
const hints: string[] = [];
const stepTypeById = new Map(pipeline.steps.map((s) => [s.id, s.type]));
for (const step of pipeline.steps) {
validateFromPaths(step.input, step.id, stepTypeById, dispatcher, hints);
}
return hints;
}
export function validatePipelineRuntimeInput(
pipeline: PipelineDefinition,
runtimeInput: Record<string, unknown>,
): Record<string, unknown> {
if (!pipeline.inputs) return runtimeInput;
const result = validateInputAgainstSchema(pipeline.inputs, runtimeInput);
if (result.ok) return result.value;
throw new PipelineError("pipeline_validation_error", "Invalid pipeline runtime input", {
details: { issues: result.issues },
});
}
export function validatePipeline(
pipeline: PipelineDefinition,
dispatcher: StepDispatcher = getDefaultStepDispatcher(),
): void {
const issues = collectPipelineIssues(pipeline, dispatcher);
if (issues.length > 0) {
throw new PipelineError("pipeline_validation_error", "Invalid pipeline definition", {
details: { issues },
});
}
}
function validateWorkflowStructure(pipeline: unknown): string[] {
const issues: string[] = [];
if (!isRecord(pipeline)) {
issues.push("structural: pipeline must be an object");
return issues;
}
if (pipeline.version !== WORKFLOW_VERSION) {
issues.push(`structural: pipeline.version must be "${WORKFLOW_VERSION}"`);
}
if (!Array.isArray(pipeline.steps)) {
issues.push("structural: pipeline.steps must be an array");
return issues;
}
if (pipeline.steps.length === 0) {
issues.push("structural: pipeline.steps must contain at least one step");
}
for (const [index, step] of pipeline.steps.entries()) {
if (!isRecord(step)) {
issues.push(`structural: step at index ${index} must be an object`);
continue;
}
if (!step.id || typeof step.id !== "string") {
issues.push(`structural: step at index ${index} must have a string id`);
}
if (!step.type || typeof step.type !== "string") {
issues.push(`structural: step at index ${index} must have a string type`);
}
if (!isRecord(step.input)) {
issues.push(`structural: step at index ${index} must have an input object`);
}
}
return issues;
}
function collectPipelineSemanticIssues(
pipeline: PipelineDefinition,
dispatcher: StepDispatcher,
): string[] {
const issues: string[] = [];
if (pipeline.inputs) {
issues.push(...validateJsonSchema(pipeline.inputs, "pipeline.inputs"));
}
const steps = pipeline.steps;
const stepIds = new Set<string>();
for (const [index, step] of steps.entries()) {
const stepLabel = step.id || `#${index}`;
if (stepIds.has(step.id)) issues.push(`duplicate step id "${step.id}"`);
stepIds.add(step.id);
if (!dispatcher.hasStep(step.type)) {
issues.push(`semantic: step "${stepLabel}" references unknown type "${step.type}"`);
}
if (
step.dependsOn !== undefined &&
(!Array.isArray(step.dependsOn) || !step.dependsOn.every((item) => typeof item === "string"))
) {
issues.push(`semantic: step "${stepLabel}" dependsOn must be an array of step id strings`);
}
if (step.retry !== undefined) validateRetryPolicy(step.retry, stepLabel, issues);
if (step.timeout !== undefined && parseTimeoutSeconds(step.timeout) === undefined) {
issues.push(
`semantic: step "${stepLabel}" timeout must be a positive number of seconds or duration string`,
);
}
}
// Check dependency references
for (const step of steps) {
for (const dep of step.dependsOn ?? []) {
if (!stepIds.has(dep)) {
issues.push(`semantic: step "${step.id}" references missing step "${dep}"`);
}
if (dep === step.id) {
issues.push(`semantic: step "${step.id}" references itself`);
}
}
// Check $from references in input
collectFromReferences(step.input, step.id, stepIds, issues);
}
// Cycle detection
const dependencies = new Map<string, Set<string>>();
for (const step of steps) {
const deps = new Set<string>(step.dependsOn ?? []);
collectFromDependencies(step.input, deps);
if (step.when !== undefined) collectFromDependencies(step.when, deps);
dependencies.set(step.id, deps);
}
for (const cycle of findCycles(dependencies)) {
issues.push(`semantic: pipeline graph contains cycle: ${cycle.join(" -> ")}`);
}
return issues;
}
function collectFromReferences(
value: unknown,
stepId: string,
stepIds: Set<string>,
issues: string[],
): void {
if (Array.isArray(value)) {
value.forEach((item) => collectFromReferences(item, stepId, stepIds, issues));
return;
}
if (!isRecord(value)) return;
if ("$from" in value && typeof value.$from === "string") {
if (!stepIds.has(value.$from)) {
issues.push(`semantic: step "${stepId}" $from references missing step "${value.$from}"`);
}
}
for (const child of Object.values(value)) {
collectFromReferences(child, stepId, stepIds, issues);
}
}
function validateRetryPolicy(policy: unknown, stepLabel: string, issues: string[]): void {
if (!isRecord(policy)) {
issues.push(`step "${stepLabel}" retry must be an object`);
return;
}
const maxAttempts = policy.maxAttempts;
if (
maxAttempts !== undefined &&
(typeof maxAttempts !== "number" || !Number.isInteger(maxAttempts) || maxAttempts < 1)
) {
issues.push(`step "${stepLabel}" retry.maxAttempts must be a positive integer`);
}
if (
policy.backoff !== undefined &&
policy.backoff !== "none" &&
policy.backoff !== "linear" &&
policy.backoff !== "exponential"
) {
issues.push(`step "${stepLabel}" retry.backoff must be none, linear, or exponential`);
}
}
function validateFromPaths(
value: unknown,
stepId: string,
stepTypeById: Map<string, string>,
dispatcher: StepDispatcher,
issues: string[],
): void {
if (Array.isArray(value)) {
value.forEach((item) => validateFromPaths(item, stepId, stepTypeById, dispatcher, issues));
return;
}
if (!isRecord(value)) return;
if ("$from" in value && typeof value.$from === "string" && typeof value.path === "string") {
const sourceType = stepTypeById.get(value.$from);
if (sourceType) {
const schema = dispatcher.getOutputSchema(sourceType);
if (schema) {
const path = value.path as string;
const matched = schema.paths.some((p) => path === p.path || path.startsWith(p.path + "/"));
if (!matched) {
const available = schema.paths.map((p) => p.path).join(", ");
issues.push(
`hint: step "${stepId}" references path "${path}" from "${value.$from}" (${sourceType}), ` +
`which is not a known output path. Known paths: ${available}`,
);
}
}
}
return;
}
for (const child of Object.values(value)) {
validateFromPaths(child, stepId, stepTypeById, dispatcher, issues);
}
}
function findCycles(dependencies: Map<string, Set<string>>): string[][] {
const cycles: string[][] = [];
const visiting = new Set<string>();
const visited = new Set<string>();
const stack: string[] = [];
const visit = (id: string) => {
if (visiting.has(id)) {
const start = stack.indexOf(id);
cycles.push([...stack.slice(start), id]);
return;
}
if (visited.has(id)) return;
visiting.add(id);
stack.push(id);
for (const dependency of dependencies.get(id) ?? []) {
if (dependencies.has(dependency)) visit(dependency);
}
stack.pop();
visiting.delete(id);
visited.add(id);
};
for (const id of dependencies.keys()) visit(id);
return cycles;
}
+282
View File
@@ -0,0 +1,282 @@
import type { Command } from "bailian-cli-core";
import { BailianError } from "bailian-cli-core";
import { ExitCode } from "bailian-cli-core";
import { DOCS_HOSTS, GLOBAL_OPTIONS, type Region } from "bailian-cli-core";
import { commands } from "./commands/catalog.ts";
export type { Command, OptionDef } from "bailian-cli-core";
interface CommandNode {
command?: Command;
children: Map<string, CommandNode>;
}
class CommandRegistry {
private root: CommandNode = { children: new Map() };
constructor(commands: Record<string, Command>) {
for (const [path, cmd] of Object.entries(commands)) {
this.register(path, cmd);
}
}
private register(path: string, command: Command): void {
const parts = path.split(" ");
let node = this.root;
for (const part of parts) {
if (!node.children.has(part)) {
node.children.set(part, { children: new Map() });
}
node = node.children.get(part)!;
}
node.command = command;
}
getAllCommands(): Command[] {
const commands: Command[] = [];
const traverse = (node: CommandNode) => {
if (node.command) commands.push(node.command);
for (const child of node.children.values()) {
traverse(child);
}
};
traverse(this.root);
return commands;
}
isGroupPath(commandPath: string[]): boolean {
let node = this.root;
for (const part of commandPath) {
const child = node.children.get(part);
if (!child) return false;
node = child;
}
return !node.command && node.children.size > 0;
}
resolve(commandPath: string[]): { command: Command; extra: string[] } {
let node = this.root;
const matched: string[] = [];
for (const part of commandPath) {
const child = node.children.get(part);
if (!child) break;
node = child;
matched.push(part);
}
if (node.command) {
return { command: node.command, extra: commandPath.slice(matched.length) };
}
// Single child: auto-forward (e.g. `bl config` → `bl config show`)
if (matched.length > 0 && node.children.size === 1) {
const [, child] = node.children.entries().next().value as [string, CommandNode];
if (child.command) {
return { command: child.command, extra: commandPath.slice(matched.length) };
}
}
// If we matched some path but no command, show help for that group
if (matched.length > 0 && node.children.size > 0) {
const subcommands = Array.from(node.children.entries())
.map(([name, n]) => {
if (n.command) return ` ${matched.join(" ")} ${name} ${n.command.description}`;
const subs = Array.from(n.children.keys()).join(", ");
return ` ${matched.join(" ")} ${name} [${subs}]`;
})
.join("\n");
throw new BailianError(
`Unknown command: bl ${commandPath.join(" ")}\n\nAvailable commands:\n${subcommands}`,
ExitCode.USAGE,
`bl ${matched.join(" ")} --help`,
);
}
throw new BailianError(
`Unknown command: bl ${commandPath.join(" ")}`,
ExitCode.USAGE,
"bl --help",
);
}
private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string {
const entries: Array<{ path: string; desc: string }> = [];
const collect = (node: CommandNode, prefix: string) => {
for (const [name, child] of node.children) {
const fullPath = prefix ? `${prefix} ${name}` : name;
if (child.command) {
entries.push({ path: fullPath, desc: child.command.description });
}
if (child.children.size > 0) {
collect(child, fullPath);
}
}
};
collect(this.root, "");
const maxLen = Math.max(...entries.map((e) => e.path.length));
return entries.map((e) => ` ${a(e.path.padEnd(maxLen + 2))} ${d(e.desc)}`).join("\n");
}
private buildGlobalFlagLines(a: (s: string) => string, d: (s: string) => string): string {
const maxLen = Math.max(...GLOBAL_OPTIONS.map((o) => o.flag.length));
return GLOBAL_OPTIONS.map((o) => ` ${a(o.flag.padEnd(maxLen + 2))} ${d(o.description)}`).join(
"\n",
);
}
// Color helpers — no-ops when output is not a TTY
private bold = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[1m${s}\x1b[0m` : s);
private accent = (s: string, out: NodeJS.WriteStream) =>
out.isTTY ? `\x1b[38;2;59;130;246m${s}\x1b[0m` : s;
private dim = (s: string, out: NodeJS.WriteStream) => (out.isTTY ? `\x1b[2m${s}\x1b[0m` : s);
printHelp(
commandPath: string[],
out: NodeJS.WriteStream = process.stdout,
region: Region = "cn",
): void {
if (commandPath.length === 0) {
this.printRootHelp(out);
return;
}
let node = this.root;
for (const part of commandPath) {
const child = node.children.get(part);
if (!child) {
this.printRootHelp(out);
return;
}
node = child;
}
if (node.command) {
this.printCommandHelp(node.command, out, region);
return;
}
// Group help (e.g. `bl auth --help`)
const prefix = commandPath.join(" ");
out.write(`\n${this.bold("Usage:", out)} bl ${prefix} <command> [flags]\n\n`);
out.write(`${this.bold("Commands:", out)}\n`);
this.printChildren(node, prefix, out);
if (prefix === "pipeline") {
this.printPipelineQuickStart(out);
}
out.write("\n");
}
private printPipelineQuickStart(out: NodeJS.WriteStream): void {
const b = (s: string) => this.bold(s, out);
const d = (s: string) => this.dim(s, out);
out.write(`
${b("Minimal workflow.yaml:")}
${d(" version: workflow/v1")}
${d(" steps:")}
${d(" - id: chat")}
${d(" type: text/chat")}
${d(" input:")}
${d(' message: "Who are you?"')}
${d(' system: "You are a concise assistant."')}
${b("Try it:")}
${d(" bl pipeline validate workflow.yaml")}
${d(" bl pipeline run workflow.yaml --dry-run --output json")}
`);
}
private printRootHelp(out: NodeJS.WriteStream): void {
// Bailian brand color: #615ced → RGB(97, 92, 237)
const LOGO = [
"██████╗ █████╗ ██╗██╗ ██╗ █████╗ ███╗ ██╗",
"██╔══██╗██╔══██╗██║██║ ██║██╔══██╗████╗ ██║",
"██████╔╝███████║██║██║ ██║███████║██╔██╗ ██║",
"██╔══██╗██╔══██║██║██║ ██║██╔══██║██║╚██╗██║",
"██████╔╝██║ ██║██║███████╗██║██║ ██║██║ ╚████║",
"╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝",
];
const PURPLE = "\x1b[38;2;97;92;237m";
const RESET = "\x1b[0m";
out.write("\n");
for (const line of LOGO) {
if (out.isTTY) {
out.write(`${PURPLE}${line}${RESET}\n`);
} else {
out.write(line + "\n");
}
}
const b = (s: string) => this.bold(s, out);
const a = (s: string) => this.accent(s, out);
const d = (s: string) => this.dim(s, out);
const commandLines = this.buildResourceLines(a, d);
const globalFlagLines = this.buildGlobalFlagLines(a, d);
out.write(`
${b("Usage:")} bl <resource> <command> [flags]
${b("Commands:")}
${commandLines}
${b("Global Flags:")}
${globalFlagLines}
${b("Getting Help:")}
${d("Add --help after any command to see its full list of options, defaults,")}
${d("and usage examples. For example:")} bl text chat --help
`);
}
private printCommandHelp(cmd: Command, out: NodeJS.WriteStream, region: Region = "cn"): void {
const b = (s: string) => this.bold(s, out);
const a = (s: string) => this.accent(s, out);
const d = (s: string) => this.dim(s, out);
out.write(`\n${cmd.description}\n`);
if (cmd.usage) out.write(`${b("Usage:")} ${cmd.usage}\n`);
if (cmd.options && cmd.options.length > 0) {
const maxLen = Math.max(...cmd.options.map((o) => o.flag.length));
out.write(`\n${b("Options:")}\n`);
for (const opt of cmd.options) {
out.write(` ${a(opt.flag.padEnd(maxLen + 2))} ${d(opt.description)}\n`);
}
}
if (cmd.examples && cmd.examples.length > 0) {
out.write(`\n${b("Examples:")}\n`);
for (const ex of cmd.examples) {
out.write(` ${d(ex)}\n`);
}
}
if (cmd.apiDocs) {
out.write(`\n${b("API Reference:")} ${d(DOCS_HOSTS[region] + cmd.apiDocs)}\n`);
}
out.write(
`\n${d("Global flags (--api-key, --output, --quiet, etc.) are always available.")}\n`,
);
out.write(`${d("Run")} bl --help ${d("for the full list.")}\n`);
}
private printChildren(node: CommandNode, prefix: string, out: NodeJS.WriteStream): void {
const entries: Array<{ fullName: string; description: string }> = [];
const collect = (n: CommandNode, p: string) => {
for (const [name, child] of n.children) {
if (child.command)
entries.push({ fullName: `${p} ${name}`, description: child.command.description });
if (child.children.size > 0) collect(child, `${p} ${name}`);
}
};
collect(node, prefix);
const maxLen = Math.max(...entries.map((e) => e.fullName.length));
for (const { fullName, description } of entries) {
out.write(` ${this.accent(fullName.padEnd(maxLen), out)} ${this.dim(description, out)}\n`);
}
}
}
export const registry = new CommandRegistry(commands);
+16
View File
@@ -0,0 +1,16 @@
/**
* User-facing Aliyun Bailian / Model Studio console URLs.
*
* Single source of truth for all `bailian.console.aliyun.com/*` references
* across cli source. Currently pinned to cn-beijing — expand to a region map
* if/when overseas console support is added.
*/
/** Root entry — generic console landing, region picker. */
export const BAILIAN_CONSOLE_ROOT = "https://bailian.console.aliyun.com";
/** Region-pinned console base. Add a region map if/when overseas is supported. */
export const BAILIAN_CONSOLE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing`;
/** Direct deep link to API key management page. */
export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`;
+25
View File
@@ -0,0 +1,25 @@
/** Current command path (e.g. `["auth","login"]`) for help-on-missing; set by `main` before `execute`. */
let executingCommandPath: string[] = [];
let printCommandHelpImpl: ((commandPath: string[], out: NodeJS.WriteStream) => void) | null = null;
export function setExecutingCommandPath(path: string[]): void {
executingCommandPath = path;
}
export function getExecutingCommandPath(): string[] {
return executingCommandPath;
}
export function registerCommandHelpPrinter(
fn: (commandPath: string[], out: NodeJS.WriteStream) => void,
): void {
printCommandHelpImpl = fn;
}
/** Print help for the command currently being executed (must call `setExecutingCommandPath` first). */
export function printCurrentCommandHelp(out: NodeJS.WriteStream = process.stderr): void {
if (printCommandHelpImpl && executingCommandPath.length > 0) {
printCommandHelpImpl(executingCommandPath, out);
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Generic concurrent execution utility.
*
* Allows any command to run N parallel API requests and aggregate results.
* Used via the global `--concurrent <N>` flag.
*
* @example
* const results = await runConcurrent(3, config, () => callApi());
*/
import type { Config, GlobalFlags } 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);
}
/**
* Run an async task N times concurrently.
* 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")
*/
export async function runConcurrent<T>(
n: number,
config: Config,
task: (index: number) => Promise<T>,
label = "requests",
): Promise<T[]> {
if (n <= 1) {
const result = await task(0);
return [result];
}
if (!config.quiet) {
process.stderr.write(`[Concurrent: ${n} ${label}]\n`);
}
const tasks = Array.from({ length: n }, (_, i) => task(i));
return Promise.all(tasks);
}
/**
* Parallel download helper — downloads multiple URLs concurrently.
*
* @param items Array of { url, destPath } pairs
* @param downloadFn Download function (url, destPath, opts) => Promise
* @param opts Options passed to download function
* @returns Array of saved file paths in order
*/
export async function downloadParallel(
items: Array<{ url: string; destPath: string }>,
downloadFn: (url: string, destPath: string, opts?: { quiet?: boolean }) => Promise<unknown>,
opts?: { quiet?: boolean },
): Promise<string[]> {
return Promise.all(
items.map(({ url, destPath }) => downloadFn(url, destPath, opts).then(() => destPath)),
);
}
+78
View File
@@ -0,0 +1,78 @@
import { createWriteStream, mkdirSync, unlinkSync } from "fs";
import { dirname } from "path";
import { BailianError, ExitCode, trackingHeaders } from "bailian-cli-core";
import { createProgressBar } from "../output/progress.ts";
import type { ReadableStreamReadResult } from "stream/web";
export async function downloadFile(
url: string,
destPath: string,
opts?: { quiet?: boolean },
): Promise<{ size: number }> {
const res = await fetch(url, {
headers: trackingHeaders(),
});
if (!res.ok) {
throw new BailianError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL);
}
const contentLength = Number(res.headers.get("content-length") || 0);
const reader = res.body?.getReader();
if (!reader) throw new BailianError("No response body", ExitCode.GENERAL);
mkdirSync(dirname(destPath), { recursive: true });
const writer = createWriteStream(destPath);
const progress =
contentLength > 0 && !opts?.quiet ? createProgressBar(contentLength, "Downloading") : null;
let received = 0;
let completed = false;
try {
const writeError = new Promise<never>((_, reject) => {
writer.on("error", reject);
});
while (true) {
const { done, value } = (await Promise.race([
reader.read(),
writeError,
])) as ReadableStreamReadResult<Uint8Array>;
if (done) break;
const ok = writer.write(value);
if (!ok) await new Promise<void>((resolve) => writer.once("drain", () => resolve()));
received += value.byteLength;
progress?.update(received);
}
completed = true;
} finally {
reader.releaseLock();
progress?.finish();
await new Promise<void>((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
writer.end();
});
if (!completed) {
try {
unlinkSync(destPath);
} catch {
/* best effort */
}
}
}
return { size: received };
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
+51
View File
@@ -0,0 +1,51 @@
import {
BailianError,
ExitCode,
isInteractive,
maskToken,
readConfigFile,
writeConfigFile,
type Config,
} from "bailian-cli-core";
import { promptText, promptConfirm } from "../output/prompt.ts";
export async function ensureApiKey(config: Config): Promise<void> {
if (config.apiKey || config.fileApiKey || config.accessTokenEnv || config.fileAccessToken) return;
const envKey = process.env.DASHSCOPE_API_KEY;
let key: string | undefined;
if (envKey) {
if (!isInteractive({ nonInteractive: config.nonInteractive })) {
key = envKey;
} else {
const use = await promptConfirm({
message: `Found DASHSCOPE_API_KEY in environment (${maskToken(envKey)}). Save it to config file?`,
});
if (use) key = envKey;
}
}
if (!key) {
if (!isInteractive({ nonInteractive: config.nonInteractive })) {
throw new BailianError(
"No API key found.",
ExitCode.AUTH,
"Set DASHSCOPE_API_KEY environment variable, pass --api-key, or run interactively to be prompted.",
);
}
const input = await promptText({ message: "Enter your DashScope API key:" });
if (!input) throw new BailianError("API key is required.", ExitCode.AUTH);
key = input;
}
const data: Record<string, unknown> = {
...(readConfigFile() as Record<string, unknown>),
api_key: key,
};
await writeConfigFile(data);
config.fileApiKey = key;
const path = config.configPath ?? "~/.bailian/config.json";
process.stderr.write(`API key saved to ${path}\n`);
}

Some files were not shown because too many files have changed in this diff Show More