mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Initial commit
This commit is contained in:
@@ -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
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
vp staged
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["VoidZero.vite-plus-extension-pack"]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 5–20s 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 |
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
@@ -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) 协议授权。
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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 5–20s 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
@@ -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 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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-max:Agentic 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 |
|
||||
@@ -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,日志泄漏
|
||||
@@ -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` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
@@ -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。
|
||||
@@ -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)。
|
||||
@@ -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 的子组是反模式,新增时优先拍平为两级
|
||||
@@ -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 的命令文件作为示例)
|
||||
@@ -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` 不显示新字段,用户改了无法回查
|
||||
@@ -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,内部错误才需要新分类
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
## 文档生长的节奏
|
||||
|
||||
这套文档**不是一次写完**,是随真实工作沉淀的:
|
||||
|
||||
- 初版只有触发条件 + 骨架清单 + 空"常见漏点"
|
||||
- 每完成一次相关改动,补一两条清单或漏点
|
||||
- 长期未触发的场景文件可以归并或删除(避免文档腐化)
|
||||
@@ -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 分支判断
|
||||
@@ -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 警告或直接失败 |
|
||||
@@ -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` 的关系
|
||||
|
||||
| 维度 | E2E(Vitest) | 批量压测(`.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 limit(exit 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>`
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
outputs/
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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 5–20s 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 |
|
||||
@@ -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 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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-max:Agentic 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 |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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()?.();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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`;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user