mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
352 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9012a6330 | |||
| 92ee845bdd | |||
| 678f60be75 | |||
| d11b55b956 | |||
| e4e3f069e1 | |||
| 440cbfe6ae | |||
| 1adfe797bd | |||
| d04012b0a4 | |||
| 81539005cc | |||
| 4c566fd60e | |||
| 853ce3caae | |||
| 3b779a708d | |||
| b4a2a1c42d | |||
| 4ca3e2de80 | |||
| 1f91fa42fa | |||
| 39f12e1a78 | |||
| a853319dd0 | |||
| bc162f4678 | |||
| 12ecac4d96 | |||
| 52f6e267e6 | |||
| c9e5913034 | |||
| ba062c1a71 | |||
| a03ba673be | |||
| 8196f67300 | |||
| 4b504a1a52 | |||
| 00b4d095ed | |||
| 92719a1a21 | |||
| 310e6ead33 | |||
| ece0c8dd1c | |||
| 39f92567d3 | |||
| 8b91b9f35f | |||
| 1fbafa8b1d | |||
| 052960e269 | |||
| a8f45e93af | |||
| 84805f287c | |||
| 196b2a1f51 | |||
| de9f1a3889 | |||
| 75a45e1a2a | |||
| 64ff057fe5 | |||
| 88fcda62aa | |||
| 68336d41a2 | |||
| e246cb96d4 | |||
| 569057f4d0 | |||
| f7dbafc6d2 | |||
| f45d0e3a71 | |||
| 6d588a57b4 | |||
| 52aab33a3b | |||
| cc23b4be37 | |||
| 91cd68b46e | |||
| 054d4deb26 | |||
| cbf871c8d6 | |||
| 20b67a6628 | |||
| 0956695f28 | |||
| 96fcb1892c | |||
| 84383f1c83 | |||
| fc8351f136 | |||
| de78a281c6 | |||
| 86aec0b723 | |||
| 06f932c1bd | |||
| 1c5d1a8fcd | |||
| 6388fd11e2 | |||
| eca5403e71 | |||
| 153e176c13 | |||
| 16893b3f00 | |||
| 78977dbe4e | |||
| 7dd5431a22 | |||
| 63edde3588 | |||
| e932495560 | |||
| 2ae76e2013 | |||
| f3b85914ab | |||
| 8b99617716 | |||
| b2ca78512f | |||
| 7004e58866 | |||
| 97e47f7054 | |||
| e0f3d450ae | |||
| d37f4c07eb | |||
| ac4dbb9e88 | |||
| 155c9dc883 | |||
| 0e857775fe | |||
| 1a63fcdb5c | |||
| 58ab622e11 | |||
| e1532bf35c | |||
| 6e095a6ce5 | |||
| 94adb919b1 | |||
| a0b4666940 | |||
| e8666b33ae | |||
| d646a4e780 | |||
| 91a7106bf4 | |||
| 03405d5cc1 | |||
| 1870500f97 | |||
| 3fb0c7211c | |||
| 7b77bf6a6d | |||
| 6e5ecf8923 | |||
| 301986b669 | |||
| 0d420b6e5a | |||
| 9602209113 | |||
| 19b7aacb2e | |||
| 049eecd991 | |||
| 2907ad2625 | |||
| 0f23527bfc | |||
| 8e3f8586b0 | |||
| efb5243d0d | |||
| f66889c939 | |||
| 03f0e7c5c4 | |||
| 0a301ee641 | |||
| 66402d9868 | |||
| 4525d5df6c | |||
| 749549aa28 | |||
| 7ce018cc53 | |||
| 13ade9181f | |||
| 068ec0bfd3 | |||
| d8aa89dc6c | |||
| bd4b0ad9a5 | |||
| 2debfdba6b | |||
| 8fd072bcd1 | |||
| 49095c3a8a | |||
| c18844120a | |||
| 4dfe3ac907 | |||
| b0c48bab6b | |||
| d20e037dec | |||
| 476dd3b841 | |||
| c35f2856e5 | |||
| b3b1a08baf | |||
| 468b4d710e | |||
| deab3b3841 | |||
| d31b7f83ca | |||
| 7b08b8863e | |||
| d118875772 | |||
| d2312847eb | |||
| b5f2b8b691 | |||
| acfbc58516 | |||
| e4849224c4 | |||
| 3aa08e5d79 | |||
| 4914c5258b | |||
| a20ab54406 | |||
| e6a8bf09e7 | |||
| c6426e9e94 | |||
| 03541b4fd1 | |||
| 6dd206eda9 | |||
| e2efcfda77 | |||
| a078670445 | |||
| 8fc2fc54fb | |||
| 9bf6c6d9af | |||
| 892ae300ae | |||
| 6c4f31ddb2 | |||
| 9ff8c53d53 | |||
| a8652f350d | |||
| b1a0c0005d | |||
| f597b94c46 | |||
| 750641dd0e | |||
| 11ed19723a | |||
| 848e44eb44 | |||
| 07875c309e | |||
| 7d05649f8d | |||
| fd9bba77a9 | |||
| 0593c7eb28 | |||
| 7c9ad7d6ce | |||
| ae88f7a4ad | |||
| bd431d769f | |||
| df89ededc2 | |||
| 2ec2f34763 | |||
| 1f56feab24 | |||
| 2f9558c161 | |||
| 95eb07d04a | |||
| f9bf36c242 | |||
| cbd3c1232c | |||
| d2aa8cac17 | |||
| ead1bc0f5f | |||
| c303b51b9e | |||
| 780ca6addb | |||
| 3375fca2f8 | |||
| a966b4077f | |||
| 4745d70587 | |||
| 91e6c6f553 | |||
| eaa6b07c7d | |||
| ca69316446 | |||
| 9c8fe96a1f | |||
| 18d5c420df | |||
| 9ad85b6278 | |||
| 82bdf9ed78 | |||
| 4383eeb416 | |||
| 46d8474ec1 | |||
| 1851ec85f0 | |||
| 0797b0767f | |||
| 17f4454df4 | |||
| e67615eabd | |||
| 39513200bc | |||
| 7b5bb1c341 | |||
| e0a7c86f05 | |||
| 908439e3f9 | |||
| 7a0a083b2e | |||
| 61689ec0da | |||
| ba78d13a52 | |||
| 24abdbf450 | |||
| cbffe6c541 | |||
| d567d6af0c | |||
| 33b1df01cc | |||
| 0ba705f194 | |||
| 23d409f9ff | |||
| 70ccc6a447 | |||
| b7fba7679e | |||
| 88e5b903bc | |||
| ba1661356f | |||
| 60c49ec1ac | |||
| 07c71412cf | |||
| e2c4935e84 | |||
| 4f10b7f50c | |||
| dc5a535bf3 | |||
| ad236e9b11 | |||
| 14105547e8 | |||
| d971a04fb8 | |||
| 1590e69d67 | |||
| fd36db5cab | |||
| d74686f09f | |||
| 30a0bbbc87 | |||
| 9761932b4c | |||
| c0d30fee3d | |||
| 9cbd4aab85 | |||
| 19c4f5f2ab | |||
| af524e5487 | |||
| 4e025dda8d | |||
| 1ffcbdd80c | |||
| 8aedeca4ac | |||
| f5a7dd494b | |||
| 07dafc0fd4 | |||
| 0cd0daa18d | |||
| 5e39d1abc3 | |||
| c3df659ef0 | |||
| 1d803bb4b9 | |||
| 847b291ccc | |||
| 0ceb15b0be | |||
| dc3c02f68c | |||
| f1eeeff682 | |||
| f184c60357 | |||
| 8906af8ad1 | |||
| 3b705f2b0d | |||
| 2260c51c7e | |||
| 8c893a59ef | |||
| 611ecc1e68 | |||
| 17c1f5f7b2 | |||
| 7593baf4f6 | |||
| edb34658a1 | |||
| cbeb2bf071 | |||
| 1fd08fe1c8 | |||
| 8cb24b719b | |||
| d4c0809951 | |||
| b01d35c246 | |||
| 67ff4d2811 | |||
| a830965feb | |||
| 14583cf6d1 | |||
| c32f03e8dc | |||
| e9feb380f5 | |||
| 62ad8768e3 | |||
| e8a40cdad0 | |||
| 9bcb66b1ff | |||
| 60cad1001c | |||
| a5d078b8a0 | |||
| 685c0176ff | |||
| d24b41d452 | |||
| d320d36ba7 | |||
| f816d5cb1f | |||
| 93cecc35de | |||
| a5d055f45a | |||
| 631a9c1818 | |||
| 682321247b | |||
| d82f334a99 | |||
| f7c18276bc | |||
| c54f6a64d7 | |||
| 73143dbae2 | |||
| cb25bc4149 | |||
| 35d681f0c7 | |||
| a16afb3f0b | |||
| 2ed513124e | |||
| 062bbd4052 | |||
| f847476016 | |||
| 83ea0dfd03 | |||
| 9a5797da1e | |||
| 8c398bae57 | |||
| 14fc293ef6 | |||
| 2bcbf56282 | |||
| ce64d628bb | |||
| 3ca8da8e75 | |||
| 6c4ac80882 | |||
| bd4644448a | |||
| a0ab35acf1 | |||
| 3f29f93ef5 | |||
| e5abd1b554 | |||
| 4749b493da | |||
| 7a65fb850c | |||
| f45b19c261 | |||
| 270412d146 | |||
| 36a60a2848 | |||
| 6c716e5120 | |||
| 2182a2239f | |||
| 51d9833a3d | |||
| e68abb6975 | |||
| 067e96689a | |||
| 173e5a7e45 | |||
| ef7aa493e0 | |||
| e67acc118f | |||
| a90a35ddef | |||
| d36bc5a82f | |||
| ada7ed32fb | |||
| 7c1be39067 | |||
| a96f3a2adf | |||
| b730a26336 | |||
| 489ba4f843 | |||
| c6afc21b11 | |||
| cc63e1ec3c | |||
| d5fb2bfaf8 | |||
| 8a0de83c24 | |||
| b36eaf34be | |||
| abe29d16b6 | |||
| 017ab86b33 | |||
| d20eea5c1c | |||
| b9e2d75ea0 | |||
| 2f22b333fa | |||
| 8023809666 | |||
| 240ce9ae3e | |||
| 386ff0fdc0 | |||
| a72f0508c3 | |||
| 65c6a358ef | |||
| 3734a6e8b9 | |||
| c070699fb2 | |||
| 8604567ce4 | |||
| 8b4dceafab | |||
| 00b1bfe7a7 | |||
| 94120d8a2b | |||
| fabf8e761d | |||
| da0ae26120 | |||
| ffc4aecce1 | |||
| c167bba32c | |||
| 1544af1f44 | |||
| c56c394527 | |||
| 418596b960 | |||
| dd56b04569 | |||
| db6ee7a5f0 | |||
| 6a0d39c726 | |||
| 822c4e6bfe | |||
| 5137257421 | |||
| f90ed8a0cc | |||
| f68717527a | |||
| 3395858c96 | |||
| a3c985c84e | |||
| c9f7e0b6b8 | |||
| b5abcaefd9 | |||
| d93b951d92 | |||
| d5407ae39b | |||
| 20704ff1c6 | |||
| 9742209c4c | |||
| 3689c2644f | |||
| 6317da8454 |
@@ -0,0 +1,185 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in bailian-cli (bl)
|
||||
title: "[bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug.
|
||||
|
||||
**Before submitting:** search [open issues](https://github.com/modelstudioai/cli/issues?q=is%3Aissue+is%3Aopen) for duplicates.
|
||||
|
||||
**Security:** redact API keys (`sk-...`), console tokens, internal URLs, and business prompts before pasting output.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Environment
|
||||
|
||||
- type: input
|
||||
id: cli-version
|
||||
attributes:
|
||||
label: CLI version
|
||||
description: "Output of bl --version (use only X.Y.Z, without the bl prefix)"
|
||||
placeholder: "1.2.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: skill-version
|
||||
attributes:
|
||||
label: Skill version (optional)
|
||||
description: "metadata.version from the installed bailian-cli skill, if applicable"
|
||||
placeholder: "1.2.1"
|
||||
|
||||
- type: input
|
||||
id: node-version
|
||||
attributes:
|
||||
label: Node version
|
||||
description: "Output of node --version"
|
||||
placeholder: "v18.17.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: OS
|
||||
description: "e.g. darwin 24.5.0, Ubuntu 22.04"
|
||||
placeholder: "darwin 24.5.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: region
|
||||
attributes:
|
||||
label: Region
|
||||
description: "From bl auth status or bl config show"
|
||||
options:
|
||||
- cn
|
||||
- us
|
||||
- intl
|
||||
- unknown
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Reproduction
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-command
|
||||
attributes:
|
||||
label: Command to reproduce
|
||||
description: "Exact command that failed. Redact --api-key, sk-..., and sensitive prompts."
|
||||
render: shell
|
||||
placeholder: |
|
||||
bl video generate --prompt "sunset" --download out.mp4 --verbose
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happened instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Error output
|
||||
|
||||
Paste stderr as printed by `bl`. Include `Request ID` when present — it helps us trace logs.
|
||||
|
||||
- type: textarea
|
||||
id: full-output
|
||||
attributes:
|
||||
label: Full output
|
||||
description: Error, Hint, Status, Request ID, Exit code, etc.
|
||||
render: shell
|
||||
placeholder: |
|
||||
Error: Generation completed but no images returned.
|
||||
Hint: ...
|
||||
Status: HTTP 200 (...)
|
||||
Request ID: ...
|
||||
Exit code: 1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: json-error
|
||||
attributes:
|
||||
label: JSON error (optional)
|
||||
description: "Re-run with --output json and paste the error object if available"
|
||||
render: json
|
||||
placeholder: |
|
||||
{
|
||||
"error": {
|
||||
"code": 1,
|
||||
"message": "...",
|
||||
"http_status": 200,
|
||||
"api_code": "...",
|
||||
"request_id": "..."
|
||||
}
|
||||
}
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Troubleshooting already tried
|
||||
|
||||
- type: checkboxes
|
||||
id: already-tried
|
||||
attributes:
|
||||
label: Already tried
|
||||
options:
|
||||
- label: "bl update and skill version aligned with CLI"
|
||||
- label: "bl auth status OK for this command"
|
||||
- label: "Different network / region — still reproduces"
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Additional context
|
||||
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
label: How often does this happen?
|
||||
options:
|
||||
- Always
|
||||
- Intermittent
|
||||
- Once
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: invoked-via
|
||||
attributes:
|
||||
label: How was bl invoked?
|
||||
options:
|
||||
- Terminal (manual)
|
||||
- Agent (Cursor, Claude, etc.)
|
||||
- CI / script
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes (optional)
|
||||
description: Anything else that might help — related issues, screenshots, minimal repro repo, etc.
|
||||
@@ -3,6 +3,13 @@ name: Publish
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Which package set to publish"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- bailian-cli
|
||||
- knowledge-studio-cli
|
||||
mode:
|
||||
description: "Publish mode"
|
||||
required: true
|
||||
@@ -16,13 +23,13 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ inputs.mode }}-${{ inputs.channel }}
|
||||
group: publish-${{ inputs.package }}-${{ inputs.mode }}-${{ inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-stable:
|
||||
if: inputs.mode == 'stable'
|
||||
name: publish stable to npm + tag
|
||||
name: publish stable (${{ inputs.package }}) to npm + tag
|
||||
runs-on: ubuntu-latest
|
||||
environment: production # Required Reviewers gate
|
||||
permissions:
|
||||
@@ -51,11 +58,11 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: publish-stable
|
||||
run: node tools/release/publish-stable.mjs
|
||||
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
|
||||
|
||||
publish-channel:
|
||||
if: inputs.mode == 'channel'
|
||||
name: publish beta to npm
|
||||
name: publish channel (${{ inputs.package }}) to npm
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # no tag, no Release; just publish
|
||||
@@ -83,4 +90,4 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: publish-channel
|
||||
run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}"
|
||||
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
|
||||
|
||||
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
tools/generated
|
||||
.node-version
|
||||
|
||||
*.local
|
||||
|
||||
@@ -33,6 +34,7 @@ tools/generated
|
||||
.claude/worktrees/
|
||||
.claude/settings.json
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.cursor/
|
||||
.qwen/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Regenerate skill reference + SKILL metadata (needs bailian-cli-core dist).
|
||||
# Regenerate skill reference + SKILL metadata from source (no package build).
|
||||
pnpm run sync:skill-assets
|
||||
|
||||
# Stage generator output so it is included in this commit.
|
||||
|
||||
@@ -1,82 +1,93 @@
|
||||
# bailian-cli — AI 维护指南
|
||||
|
||||
本文件是 AI agent 维护本仓库时的契约。每次进入项目首先读这里,从下方"业务场景索引"挑一条,跳到对应的详细文档,按它的清单完成改动。
|
||||
本文件是 AI agent 维护本仓库时的契约。每次进入项目先读这里,从"业务场景索引"挑一条,再进入对应 `docs/agents/*.md` 清单。
|
||||
|
||||
## 项目地图
|
||||
|
||||
monorepo 双包结构:
|
||||
monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"分层:
|
||||
|
||||
- `packages/cli` — `bailian-cli` 包,CLI 命令、UI、入口
|
||||
- `packages/core` — `bailian-cli-core` 包,鉴权 / HTTP / 类型,纯逻辑层
|
||||
- `packages/core` — `bailian-cli-core`,纯逻辑层:鉴权、配置、HTTP client、错误、类型、文件工具
|
||||
- `packages/runtime` — `bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline、Command Pack host
|
||||
- `packages/commands` — `bailian-cli-commands`,可复用命令实现库,只导出 command,不决定产品路径
|
||||
- `packages/cli` — `bailian-cli`,完整 `bl` 产品入口;`src/commands.ts` 组装 `bl` 暴露的命令路径
|
||||
- `packages/kscli` — `knowledge-studio-cli`,Knowledge Studio 专用入口;`src/main.ts` 复用 commands 并重映射为 `kscli` 路径
|
||||
|
||||
### `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/
|
||||
packages/cli/src/main.ts # bl 入口,注入 binName/version/clientName/npmPackage
|
||||
packages/cli/src/commands.ts # bl 产品命令 map,tools/generate-reference.ts 也读它
|
||||
packages/cli/src/command-pack-policy.ts # bl 的 Command Pack policy
|
||||
packages/kscli/src/main.ts # kscli 入口和命令 map
|
||||
|
||||
packages/commands/src/index.ts # re-export 单个命令实现
|
||||
packages/commands/src/commands/ # defineCommand({ auth, flags, usageArgs, exampleArgs, run })
|
||||
|
||||
packages/runtime/src/create-cli.ts # createCli(commands, options)
|
||||
packages/runtime/src/registry.ts # 命令树解析 + 动态 help
|
||||
packages/runtime/src/middleware.ts # auth / telemetry / update / run command
|
||||
packages/runtime/src/command-packs/ # 通用 Command Pack 加载、校验、隔离安装目录和管理命令
|
||||
packages/runtime/src/urls.ts # 用户面控制台 URL
|
||||
|
||||
packages/core/src/types/command.ts # Command / flags / auth 类型
|
||||
packages/core/src/config/ # ConfigFile / Settings / source 解析
|
||||
packages/core/src/auth/ # apiKey / console credential 解析与落盘
|
||||
packages/core/src/client/ # HTTP client / endpoints / console gateway
|
||||
```
|
||||
|
||||
Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/cli` 安装。`tools/generate-reference.ts` 从 `catalog.ts` 生成命令手册到 `skills/bailian-cli/reference/`(纳入 git);与 `tools/sync-skill-metadata.ts` 一起在 **pre-commit**(`.vite-hooks/pre-commit`)及根脚本 `pnpm run sync:skill-assets` 中执行。
|
||||
|
||||
非代码资产:
|
||||
|
||||
- `tools/release/` — 发版自动化(CI 驱动,见 `.github/workflows/publish.yml`)
|
||||
- `tools/generate-reference.ts` — 从 `catalog.ts` 生成命令手册到 `skills/bailian-cli/reference/`
|
||||
- `tools/sync-skill-metadata.ts` — 从 `packages/cli/package.json` 同步 `skills/bailian-cli/SKILL.md` 的 `metadata.version`(与 `generate:reference` 一并由根目录 `pnpm run sync:skill-assets` 及 pre-commit 执行)
|
||||
- `README.md` / `README.zh.md` — npm 和 GitHub 主页
|
||||
Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/cli` 安装。`tools/generate-reference.ts` 从 **`packages/cli/src/commands.ts`** 生成 `skills/bailian-cli/reference/`(纳入 git);`tools/sync-skill-metadata.ts` 从 `packages/cli/package.json` 同步 `skills/bailian-cli/SKILL.md` 的 `metadata.version`。两者由根脚本 `pnpm run sync:skill-assets` 和 `.vite-hooks/pre-commit` 执行。
|
||||
|
||||
约定:
|
||||
|
||||
- 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)
|
||||
- 命令实现文件路径仍按能力放置:`packages/commands/src/commands/text/chat.ts`
|
||||
- 产品命令路径由入口 map 决定:同一个实现可暴露为 `bl knowledge retrieve` 或 `kscli retrieve`
|
||||
- `defineCommand` 只写命令元数据与逻辑: `auth`、`flags`、`usageArgs`、`exampleArgs`、`validate`、`run`
|
||||
- `usageArgs` / `exampleArgs` 不写 `bl` 或 `kscli` 前缀;runtime / reference 生成器按产品路径补前缀
|
||||
- 不再使用 `catalog.ts` 作为登记处;新增/重命名命令必须同时看命令库导出和产品入口 map
|
||||
|
||||
非代码资产:
|
||||
|
||||
- `tools/release/` — 发版自动化(CI 驱动,见 `.github/workflows/publish.yml`)
|
||||
- `tools/generate-reference.ts` — 从 `packages/cli/src/commands.ts` 生成 `skills/bailian-cli/reference/`
|
||||
- `tools/sync-skill-metadata.ts` — 同步 `skills/bailian-cli/SKILL.md` 的 `metadata.version`
|
||||
- `README.md` / `README.zh.md` — npm 和 GitHub 主页
|
||||
|
||||
## 业务场景索引
|
||||
|
||||
按当前任务从下表挑一条进入对应文档:
|
||||
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `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) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.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) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增一份 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
|
||||
## 通用约定
|
||||
|
||||
下面两条与场景无关,任何改动都适用。每次完成改动后自查。
|
||||
### 1. 发布包版本号同步
|
||||
|
||||
### 1. cli 和 core 版本号同步
|
||||
源码包的 `version` 当前保持一致: `packages/core`、`packages/runtime`、`packages/commands`、`packages/cli`、`packages/kscli`。做版本 bump 时一动多动。release 工具当前强校验 / 发布范围以 `tools/release/lib/packages.mjs` 为准;把新包纳入发布前必须同步该清单和 [publish.md](docs/agents/publish.md)。
|
||||
|
||||
`packages/cli/package.json` 和 `packages/core/package.json` 的 `version` 字段必须始终相等。一动两动。
|
||||
### 2. 分层边界
|
||||
|
||||
### 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`)
|
||||
- `core` 是纯库:不依赖 `runtime` / `commands` / 产品入口;不调 `process.exit`;新增/改动时不硬编码 `bl` / `kscli` 命令名、控制台 URL 或渠道追踪参数。当前遗留项见 [error-hint-change.md](docs/agents/error-hint-change.md) 与 [url-change.md](docs/agents/url-change.md),触碰相关代码时顺手收敛
|
||||
- `runtime` 是通用 CLI 框架:可以处理 TTY、help、错误输出、middleware,但不写具体业务命令逻辑
|
||||
- `commands` 是命令实现库:不决定产品路径;不在 `usageArgs` / `exampleArgs` / hint 里硬编码产品 bin 前缀
|
||||
- `cli` / `kscli` 是产品层:负责命令路径 map、产品 identity、README、技能 reference、发版入口
|
||||
- URL 集中在 `packages/runtime/src/urls.ts`(用户面控制台)和 `packages/core/src/config/schema.ts` / client 层(API)
|
||||
|
||||
### 3. 错误处理边界:CLI 不翻译服务端错误
|
||||
|
||||
@@ -86,18 +97,33 @@ CLI 只为「自己能权威解释的错误」发出语义化信号,服务端的
|
||||
| ---------------------------------------------------- | -------- | ----------------------------------------------------------- |
|
||||
| 命令解析、缺 flag、参数校验 | **内部** | `BailianError(USAGE)` |
|
||||
| 文件 I/O(ENOENT/EACCES/...) | **内部** | `BailianError(GENERAL)` + errno-specific hint |
|
||||
| 本地 credentials 缺失(resolver/ensure-key/AK-SK 等) | **内部** | `BailianError(AUTH)` |
|
||||
| 本地 credentials 缺失(resolver / auth stage 等) | **内部** | `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)。
|
||||
不要扮演服务端错误的翻译官——我们没有最新的错误码体系认知,二次包装只会撒谎。
|
||||
|
||||
### 4. Console Gateway 命令必须声明鉴权域
|
||||
|
||||
如果命令调用 Console Gateway,`defineCommand` 必须设置 `auth: "console"`。runtime 会基于 `CONSOLE_AUTH_FLAGS` 自动在 help 中展示 `--console-region`、`--console-site`、`--console-switch-agent`、`--workspace-id`,并由 `authStage` 解析/注入 console credential。命令不要重复声明这些凭证域 flag,也不要手动从 env/config 解析 token。
|
||||
|
||||
### 5. 禁止单字母变量命名
|
||||
|
||||
所有变量、参数、回调形参必须使用有语义的命名,不允许单字母(如 `i`、`m`、`p`、`t`、`e`、`s`)。具体表现:
|
||||
|
||||
- 回调参数: `.map((m) => ...)` → `.map((model) => ...)`, `.find((t) => ...)` → `.find((template) => ...)`
|
||||
- catch 变量: `catch (e)` → `catch (error)`
|
||||
- for-of 循环: `for (const i of items)` → `for (const item of items)`
|
||||
- 临时变量: `const s = ...` → `const strategy = ...`
|
||||
|
||||
例外: 仅当作用域极小(≤3 行)且语义从上下文完全明确时,可使用 `k`/`v`(Object.entries 的 key/value)。
|
||||
|
||||
## 完成改动后的快速验证
|
||||
|
||||
```sh
|
||||
vp check # format + lint + type check
|
||||
vp test # unit + e2e (e2e 需 API key)
|
||||
vp test # unit + e2e (真实集成需 API key / console token)
|
||||
```
|
||||
|
||||
## 这份指南本身怎么演化
|
||||
|
||||
+266
-2
@@ -1,11 +1,275 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `bailian-cli` and `bailian-cli-core` are documented here.
|
||||
All notable changes to the `bailian-cli` packages 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.
|
||||
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 `bailian-cli`, `bailian-cli-core`, `bailian-cli-runtime`, `bailian-cli-commands`, and `knowledge-studio-cli` packages share a single version number.
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Token Plan defaults now use the current text, image, and dedicated text-to-video, image-to-video, and reference-to-video models.
|
||||
- The Bailian CLI Skill now distinguishes Bailian-specific tasks from ordinary host-agent work more accurately and avoids repeated consent prompts within an approved workflow.
|
||||
- Published CLI packages now support Node.js 18.17 and later, lowering the previous minimum requirement from Node.js 22.12.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Token Plan now handles local images correctly for image editing, image-to-video, reference-to-video, and vision understanding without requiring a separately hosted URL.
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config agent`** — configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope in one command.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Bailian CLI Skill now routes only matching Bailian and multimodal tasks to `bl`, and asks for consent before provider-neutral remote or billable calls.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Full `bl auth logout` now clears the model Base URL so later logins cannot inherit a stale custom or Token Plan endpoint.
|
||||
|
||||
## [1.9.0] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Token Plan support** — log in and call supported models directly without manually configuring the endpoint.
|
||||
- **Named Config Profiles** — create, switch, and manage isolated configurations; logging in to a named Profile activates it automatically.
|
||||
- **Console Access Token automation** — generate and automatically refresh Console Access Tokens.
|
||||
- **`bl workspace init`** — initialize a Bailian workspace and activate the required services in one workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved configuration safety and consistency, including secret masking and preservation of custom configuration fields.
|
||||
|
||||
## [1.8.3] - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `bl text chat --messages-file -` failing on Windows by treating standard input as a `/dev/stdin` file path; piped JSON messages are now read from standard input correctly. (#103)
|
||||
|
||||
## [1.8.2] - 2026-07-15
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl model list` now defaults to JSON output; pass `--output text` for the table view.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl model list --enrich` now returns each model's input parameter schema (predictConfig); it was previously always empty because the console gateway response envelope was not unwrapped.
|
||||
|
||||
## [1.8.1] - 2026-07-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Expanded the Command Pack allowlist to accept an additional internal command extension.
|
||||
|
||||
## [1.8.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl model list`** — browse the Bailian model marketplace: list model families or show full details for a single family (`--model`), with filters for provider, capability, feature, and context-window, pagination (`--page` / `--page-size`), pricing, and `--enrich` for richer metadata.
|
||||
- **`bl usage summary`** — a unified usage view combining free-tier quota and a recent usage overview; `--days` sets the overview window (default 7).
|
||||
- **Command Pack host support** — added support for allowlisted internal command extensions.
|
||||
- **Audio & image fine-tuning** — `bl finetune audio create` (CosyVoice TTS) and `bl finetune image create` (Wan image generation) join the existing text flow. `bl finetune image create` supports `--generation-type t2i|i2i` to select text-to-image or image-to-image training.
|
||||
- **Audio & image deployment** — `bl deploy audio create` and `bl deploy image create` deploy fine-tuned TTS and image models as endpoints.
|
||||
- **Multimodal dataset validation** — `bl dataset upload` and `bl dataset validate` now accept `.zip` archives with `tts` and `image` schemas, validate referenced media files, and allow image archives up to 1 GB.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Fine-tune and deploy commands are now split by modality (BREAKING)**: `bl finetune create` → `bl finetune text create`, and `bl deploy create` → `bl deploy text create`. Update any scripts that use the old paths.
|
||||
- **Deployment option renamed (BREAKING)**: `--template-id` → `--deploy-spec` on deployment creation commands.
|
||||
- **Fine-tune status exit behavior changed (BREAKING)**: `bl finetune watch` no longer reserves exit code 3 for running jobs. Running and succeeded jobs return 0; failed and canceled jobs use normal CLI errors.
|
||||
- `bl deploy audio create` now defaults to `--plan mu` (model-unit billing, per the CosyVoice deployment contract); text and image continue to default to `lora`.
|
||||
- `bl finetune audio create` now validates CosyVoice training data: audio files must be `.wav`, each `wav_fn` must start with `train/`, and exactly one training file is accepted.
|
||||
- `bl quota list` and `bl quota check` now report real RPM/TPM usage against limits, adding `RPM Left` / `TPM Left` columns with remaining-quota progress bars sourced from monitoring data.
|
||||
- `bl usage free` output now shares its rendering with `bl usage summary` for consistent free-tier tables.
|
||||
- `bl advisor recommend` no longer depends on a dedicated intent-detection model to analyze your request.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Removed the `tongyi-intent-detect-v3` integration (BREAKING)** used by `bl advisor recommend`, along with the `intent_detect_base_url` config field and the `DASHSCOPE_INTENT_DETECT_BASE_URL` environment variable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Skill command-reference generation now reads product command maps directly from source and produces stable formatting during release checks.
|
||||
|
||||
## [1.7.0] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- `bl auth login --open-api` now stores Alibaba Cloud OpenAPI AK/SK credentials for Token Plan commands; `bl auth status` reports API key, console, and OpenAPI credential state separately, and `bl auth logout --open-api` clears only OpenAPI credentials.
|
||||
- `kscli` help and examples now render as Knowledge Studio paths such as `kscli search`, `kscli chat`, and `kscli retrieve`, matching the standalone CLI.
|
||||
|
||||
### Changed
|
||||
|
||||
- Token Plan commands now use the shared OpenAPI AK/SK credential flow, including persisted credentials and `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET` environment variables.
|
||||
- Auth flags are now scoped to the commands that can use them. Passing model, console, or OpenAPI credential flags to the wrong command now reports an unknown flag instead of being accepted and ignored.
|
||||
- Help and command reference output now show only the flags that apply to each command's auth mode, making model, console, and OpenAPI credentials easier to distinguish.
|
||||
- Missing required flags now return usage errors with exit code 2 instead of opening interactive prompts or printing help with exit code 0.
|
||||
- Image, video, and speech task commands now use `--async` consistently for returning task IDs without waiting; `--concurrent` is shown only on commands that support parallel requests.
|
||||
- Default command output is text unless `--output json`, `DASHSCOPE_OUTPUT=json`, or config explicitly requests JSON.
|
||||
- Update checks are throttled to once per day and can surface in non-TTY/agent runs.
|
||||
- Proxy setup now reads uppercase `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` only; lowercase proxy environment variables are ignored.
|
||||
- `bl auth login` no longer prints the onboarding quick start block after a successful login.
|
||||
|
||||
### Removed
|
||||
|
||||
- Deprecated AK/SK authentication for `bl knowledge retrieve`; use DashScope API key auth for knowledge commands.
|
||||
- Removed `--no-color`, `--non-interactive`, and `--no-wait`. Use `NO_COLOR=1` for plain output and `--async` for task submission without waiting.
|
||||
- Removed `--yes` and interactive confirmation prompts from delete/logout commands; use `--dry-run` to preview before running destructive operations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Credential-gated `--dry-run` paths now skip auth preflight so commands such as Token Plan can print request details without configured credentials.
|
||||
- `--verbose` model requests again print request method, URL, auth source, and response status details.
|
||||
|
||||
## [1.6.1] - 2026-07-03
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl vision describe` examples and skill reference now use `qwen3-vl-plus` instead of the legacy `qwen-vl-plus` model id, matching the command's default model.
|
||||
|
||||
## [1.6.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- `bl knowledge search` — semantic search across knowledge bases using the new workspace-based RAG API. Supports `--query`, `--agent-id`, `--workspace-id`, `--image` (multimodal retrieval, repeatable), and `--query-history` (JSON conversation context for multi-turn query rewriting).
|
||||
- `bl knowledge chat` — knowledge-base Q&A with SSE streaming. Supports `--message` (repeatable, with `role:content` prefix for multi-turn history), `--agent-id`, `--workspace-id`, and `--image` (multimodal). Displays real-time progress with step-change labels (retrieval, planning, generation) in interactive mode.
|
||||
- `bailian-cli-core` gains new types and endpoints for the workspace-based knowledge API: `KnowledgeSearchRequest` / `KnowledgeSearchResponse`, `KnowledgeChatRequest` / `KnowledgeChatStreamChunk` / `KnowledgeChatMessage` / `KnowledgeChatContentPart`, and `knowledgeSearchEndpoint` / `knowledgeChatEndpoint`.
|
||||
- `kscli` now ships `search` and `chat` commands alongside the existing `retrieve`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl knowledge retrieve` is now marked as deprecated in its description; use `bl knowledge search` instead.
|
||||
- `kscli` README (EN + ZH) updated to feature `search` and `chat` as the primary commands, with `retrieve` marked deprecated.
|
||||
|
||||
## [1.5.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Model fine-tuning — `bl finetune`: create, list, get, watch, and cancel jobs; fetch training logs; list checkpoints; export a checkpoint as a deployable model; and query training capability (by model or by training type). Supports `sft`, `sft-lora`, `dpo`, `dpo-lora`, and `cpt` training types.
|
||||
- Model deployment — `bl deploy`: create, list, get, update (rate limits), scale, and delete deployments; list deployable models and plans.
|
||||
- Dataset management — `bl dataset`: upload, list, get, and delete dataset files, plus `bl dataset validate` to check a local `.jsonl` before uploading (ChatML / DPO / CPT formats).
|
||||
- Token Plan management — `bl token-plan`: list subscription seats, add members, batch-assign seats, and create a per-seat API key.
|
||||
- Automatic update check: after a command finishes, the CLI checks npm for a newer release (throttled) and shows an `Update available` hint; a major stable-version gap upgrades itself automatically. Skipped with `--quiet` or when running `bl update`.
|
||||
- Composable packages: `bailian-cli-runtime` (CLI framework) and `bailian-cli-commands` (command library) are now published alongside `bailian-cli-core`, and a new sibling CLI `knowledge-studio-cli` (`kscli`) ships on top of them. `bl` behavior is unchanged.
|
||||
|
||||
### Removed
|
||||
|
||||
- `bl config export-schema` (exported CLI commands as Anthropic/OpenAI-compatible JSON tool schemas) has been removed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Console gateway commands (`bl console call`, etc.) now surface a readable message when the gateway returns a non-string `errorCode`, instead of `[object Object]`.
|
||||
|
||||
## [1.4.2] - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- `bl omni --list-voices` prints the built-in output voices (ID, name, description, language) and exits without needing an API key. The built-in voice table is expanded from 6 to 17 voices, including dialect voices such as Dylan, Sunny, and Kiki.
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl omni` default `--voice` is now `Tina` (previously `Cherry`). The `--voice` help points at `--list-voices` instead of listing every option inline.
|
||||
- `bl speech synthesize --list-voices` and its missing-`--voice` hint now include a link to the official CosyVoice voice documentation.
|
||||
- Agent skill setup guidance now covers console site selection (`--console-site domestic` / `international`) for console login and gateway commands.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl speech synthesize` corrects the `cosyvoice-v3-flash` built-in voice ID from `longanhuan` to `longanhuan_v3`.
|
||||
|
||||
## [1.4.1] - 2026-06-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Video generation now defaults to the upgraded HappyHorse 1.1 model for better quality. The 1.0 models are still available via `--model`.
|
||||
- `bl update` now keeps the agent skill in sync across all your agent apps (Claude Code, Cursor, etc.), and refreshes it even when the CLI is already up to date.
|
||||
|
||||
## [1.4.0] - 2026-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- Console gateway now supports multiple regions and sites: `cn-beijing` and `ap-southeast-1`, each with domestic and international variants, plus `switchAgent` for delegated access.
|
||||
- New global flags `--console-region`, `--console-site`, and `--console-switch-agent`; `bl console call` also gains `--site` and `--switch-agent`.
|
||||
- `bl auth login --base-url <url>` to specify the base URL when logging in with an API key.
|
||||
- `bl omni` gains a `--voice` option (Chelsie, Cherry, Ethan, Serena, Sunny, Tina; default Cherry).
|
||||
|
||||
### Changed
|
||||
|
||||
- All user-facing CLI text is now standardized to English.
|
||||
- `bl advisor recommend` internal intent/ranking model upgraded from `qwen-turbo` to `qwen-flash`.
|
||||
- Cleaner JSON output for `usage`, `quota`, and `workspace` commands.
|
||||
- `base_url` from the config file now takes priority over the `DASHSCOPE_BASE_URL` environment variable.
|
||||
- `bl config show` now displays all fields from `config.json`, with sensitive values masked.
|
||||
|
||||
### Removed
|
||||
|
||||
- The legacy `region` config field and its related options.
|
||||
- Invalid leftover code for the removed `model list` command.
|
||||
|
||||
### Fixed
|
||||
|
||||
- When the console session is not logged in or has expired, the CLI now shows a clear sign-in prompt instead of a generic gateway error.
|
||||
- Corrected `--resolution` / `--ratio` / `--duration` flag descriptions for `bl video` commands.
|
||||
|
||||
## [1.3.3] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl knowledge retrieve --help` now clearly indicates that `--api-key` is the recommended authentication method; AK/SK flags are explicitly marked as deprecated with guidance to use `--api-key` instead.
|
||||
|
||||
### Added
|
||||
|
||||
- `notes` field for command definitions — commands can now include contextual notes (auth requirements, deprecation notices, etc.) that are displayed in both `--help` output and the generated reference docs.
|
||||
|
||||
## [1.3.2] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `bl omni --audio` always returning HTTP 400 (#54); audio inputs are now understood correctly.
|
||||
|
||||
## [1.3.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl` now honors `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables (#35). Node's built-in `fetch` (undici) ignores proxy env vars by default, causing `ECONNRESET` for users behind a VPN or corporate proxy. A global proxy dispatcher is now installed at startup when these variables are set, and the `ECONNRESET` error hint points to `export HTTPS_PROXY=http://127.0.0.1:<port>`.
|
||||
|
||||
## [1.3.0] - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- `bl knowledge retrieve` now supports API-Key authentication (DashScope gateway), in addition to AK/SK. API-Key is auto-detected and preferred when available.
|
||||
- New retrieval options: `--dense-similarity-top-k`, `--sparse-similarity-top-k`, `--rerank-model`, `--rerank-mode`, `--rerank-instruct` — supported on both API-Key and AK/SK paths.
|
||||
- `DashScopeKnowledgeRetrieveRequest` / `DashScopeKnowledgeRetrieveResponse` types and `knowledgeRetrieveEndpoint` added to `bailian-cli-core`.
|
||||
- Comprehensive E2E tests for knowledge retrieve covering both auth paths, dry-run, rerank flags, and error cases.
|
||||
|
||||
- `bl usage` command group:
|
||||
- `bl usage free` — query free-tier quota for all models (or a specific model with `--model`).
|
||||
- `bl usage freetier` — enable (`--on`) or disable (`--off`) auto-stop for free-tier models.
|
||||
- `bl usage stats` — query model usage statistics (requires `--workspace-id`).
|
||||
- `bl quota` command group:
|
||||
- `bl quota list` — view model RPM/TPM rate limits (filter with `--model`, show all with `--all`).
|
||||
- `bl quota check` — check current RPM/TPM usage against rate limits.
|
||||
- `bl quota history` — view quota change history with pagination.
|
||||
- `bl quota request` — request a temporary quota increase for a model.
|
||||
- `bl workspace list` — list all workspaces with region and endpoint details.
|
||||
|
||||
### Changed
|
||||
|
||||
- Credential resolution priority: explicit API-Key → explicit AK/SK flags → auto-detected API-Key → fallback AK/SK from config/env.
|
||||
- `--workspace-id` is now only required for AK/SK auth, no longer mandatory for API-Key mode.
|
||||
- `--top-k` deprecated in favor of `--rerank-top-n`; emits a warning and maps to `--rerank-top-n` when used.
|
||||
- `--access-key-id` / `--access-key-secret` flags marked as deprecated (API-Key is recommended).
|
||||
- API Key and console links updated to direct key management pages across all docs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `--rerank` flag in AK/SK path now correctly sets `EnableReranking` instead of the non-functional `Rerank: true` boolean.
|
||||
|
||||
## [1.2.1] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
+265
-2
@@ -1,11 +1,274 @@
|
||||
# 更新日志
|
||||
|
||||
`bailian-cli` 和 `bailian-cli-core` 的所有重要变更都记录在此。
|
||||
`bailian-cli` 系列包的所有重要变更都记录在此。
|
||||
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。两个包共享一个版本号,总是一起发布。
|
||||
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/spec/v2.0.0.html)。`bailian-cli`、`bailian-cli-core`、`bailian-cli-runtime`、`bailian-cli-commands`、`knowledge-studio-cli` 共享一个版本号。
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
- Token Plan 默认模型已更新为当前文本、图片,以及文生视频、图生视频和参考生视频的专用模型。
|
||||
- 百炼 CLI Skill 现在能更准确地区分百炼专属任务与普通宿主 Agent 任务,并避免在已授权的工作流中重复征求同意。
|
||||
- 已发布的 CLI 包现在支持 Node.js 18.17 及以上版本,最低版本要求由 Node.js 22.12 下调至 18.17。
|
||||
|
||||
### 修复
|
||||
|
||||
- Token Plan 现在能在图片编辑、图生视频、参考生视频和视觉理解中正确处理本地图片,无需另行托管为 URL。
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent`** —— 一键配置 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 和 Codex 接入百炼模型服务。
|
||||
|
||||
### 变更
|
||||
|
||||
- 百炼 CLI Skill 现在只将匹配的百炼任务与多模态任务路由到 `bl`,并会在调用与平台无关的远程或计费能力前征求同意。
|
||||
|
||||
### 修复
|
||||
|
||||
- 完整执行 `bl auth logout` 时会同时清除模型 Base URL,避免后续登录继承失效的自定义或 Token Plan 接入地址。
|
||||
|
||||
## [1.9.0] - 2026-07-17
|
||||
|
||||
### 新增
|
||||
|
||||
- **支持 Token Plan** —— 登录后即可直接调用支持的模型,无需手动配置接入地址。
|
||||
- **命名 Config Profile** —— 支持创建、切换和管理相互隔离的配置,登录后会自动激活当前 Profile。
|
||||
- **Console Access Token 自动化** —— 支持生成并自动刷新 Console Access Token。
|
||||
- **`bl workspace init`** —— 一站式完成百炼工作空间初始化和所需服务开通。
|
||||
|
||||
### 修复
|
||||
|
||||
- 提升配置安全性与一致性,包括密钥脱敏和自定义配置字段保留。
|
||||
|
||||
## [1.8.3] - 2026-07-16
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Windows 上 `bl text chat --messages-file -` 将标准输入当作 `/dev/stdin` 文件路径读取的问题;通过管道传入的 JSON 消息现在可以从标准输入正常读取。(#103)
|
||||
|
||||
## [1.8.2] - 2026-07-15
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl model list` 现在默认以 JSON 输出;需要表格视图请传 `--output text`。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl model list --enrich` 现在能正确返回每个模型的输入参数 schema(predictConfig);此前因未解包控制台网关响应信封而始终为空。
|
||||
|
||||
## [1.8.1] - 2026-07-14
|
||||
|
||||
### 变更
|
||||
|
||||
- 扩展 Command Pack 白名单,允许加载额外的内部命令扩展。
|
||||
|
||||
## [1.8.0] - 2026-07-13
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl model list`** —— 浏览百炼模型市场:列出模型家族,或用 `--model` 查看单个家族的完整详情;支持按 provider、能力、特性、上下文窗口过滤,分页(`--page` / `--page-size`)、价格展示,以及 `--enrich` 获取更丰富的元数据。
|
||||
- **`bl usage summary`** —— 统一用量视图,一屏合并免费额度与近期用量概览;`--days` 设置概览时间窗口(默认 7 天)。
|
||||
- **Command Pack 宿主支持** —— 新增面向白名单内部命令扩展包的加载能力。
|
||||
- **音频与图像精调** —— 在原有文本流程之外新增 `bl finetune audio create`(CosyVoice 语音合成)与 `bl finetune image create`(万相图像生成)。`bl finetune image create` 支持 `--generation-type t2i|i2i` 显式选择文生图或图生图训练。
|
||||
- **音频与图像部署** —— `bl deploy audio create` 与 `bl deploy image create` 可将精调后的语音合成与图像模型部署为推理接入点。
|
||||
- **多模态数据集校验** —— `bl dataset upload` 与 `bl dataset validate` 现在支持使用 `tts`、`image` schema 的 `.zip` 压缩包,可校验包内引用的媒体文件,图像数据压缩包上限提升至 1 GB。
|
||||
|
||||
### 变更
|
||||
|
||||
- **精调与部署命令按模态拆分(BREAKING)**:`bl finetune create` → `bl finetune text create`,`bl deploy create` → `bl deploy text create`。请更新使用旧路径的脚本。
|
||||
- **部署参数重命名(BREAKING)**:部署创建命令的 `--template-id` 更名为 `--deploy-spec`。
|
||||
- **精调状态退出行为变更(BREAKING)**:`bl finetune watch` 不再使用退出码 3 表示任务运行中;运行中与成功均返回 0,失败与取消使用 CLI 的常规错误流程。
|
||||
- `bl deploy audio create` 默认使用 `--plan mu`(按模型单元计费,符合 CosyVoice 部署契约);文本与图像仍默认 `lora`。
|
||||
- `bl finetune audio create` 现在会校验 CosyVoice 训练数据:音频必须为 `.wav`,每条 `wav_fn` 必须以 `train/` 开头,且只接受一个训练文件。
|
||||
- `bl quota list` 与 `bl quota check` 现在会基于监控数据展示真实的 RPM/TPM 用量与限额,新增 `RPM Left` / `TPM Left` 列及剩余额度进度条。
|
||||
- `bl usage free` 的输出现在与 `bl usage summary` 共用渲染逻辑,免费额度表格更一致。
|
||||
- `bl advisor recommend` 不再依赖独立的意图识别模型来分析你的需求。
|
||||
|
||||
### 已移除
|
||||
|
||||
- **移除 `bl advisor recommend` 使用的 `tongyi-intent-detect-v3` 集成(BREAKING)**,同时移除 `intent_detect_base_url` 配置字段与 `DASHSCOPE_INTENT_DETECT_BASE_URL` 环境变量。
|
||||
|
||||
### 修复
|
||||
|
||||
- Skill 命令参考文档生成现在直接读取产品命令源码,并在发布检查中保持稳定格式。
|
||||
|
||||
## [1.7.0] - 2026-07-09
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl auth login --open-api` 现在可以保存阿里云 OpenAPI AK/SK 凭据,供 Token Plan 命令使用;`bl auth status` 会分别展示 API Key、控制台和 OpenAPI 凭据状态,`bl auth logout --open-api` 可只清除 OpenAPI 凭据。
|
||||
- `kscli` 的 help 与示例现在展示为 `kscli search`、`kscli chat`、`kscli retrieve` 等 Knowledge Studio 独立入口路径。
|
||||
|
||||
### 变更
|
||||
|
||||
- Token Plan 命令统一使用 OpenAPI AK/SK 凭据流程,支持登录持久化凭据和 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET` 环境变量。
|
||||
- 鉴权 flag 现在只对可使用它们的命令生效。把模型、控制台或 OpenAPI 凭据 flag 传给错误的命令时,现在会报 unknown flag,而不是接受后忽略。
|
||||
- help 与命令参考现在只展示当前命令鉴权域适用的 flag,更容易区分模型、控制台和 OpenAPI 凭据。
|
||||
- 缺少必填 flag 时现在返回用法错误并以退出码 2 退出,不再进入交互式补全或打印 help 后以退出码 0 退出。
|
||||
- 图片、视频、语音任务类命令现在统一用 `--async` 表示提交任务后不等待;`--concurrent` 只在支持并发请求的命令上展示。
|
||||
- 命令默认输出为文本;仅在显式设置 `--output json`、`DASHSCOPE_OUTPUT=json` 或配置文件要求 JSON 时输出 JSON。
|
||||
- 更新检查节流调整为每天一次,并可在非 TTY / agent 场景展示更新提示。
|
||||
- 代理配置现在只读取大写 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,忽略小写代理环境变量。
|
||||
- `bl auth login` 成功后不再额外打印 onboarding quick start 内容。
|
||||
|
||||
### 已移除
|
||||
|
||||
- 移除 `bl knowledge retrieve` 已废弃的 AK/SK 鉴权;知识库命令请使用 DashScope API Key。
|
||||
- 移除 `--no-color`、`--non-interactive` 和 `--no-wait`。纯文本输出使用 `NO_COLOR=1`,提交任务后不等待使用 `--async`。
|
||||
- 移除删除 / 登出类命令的 `--yes` 与交互式确认提示;执行破坏性操作前请用 `--dry-run` 预览。
|
||||
|
||||
### 修复
|
||||
|
||||
- 需要凭据的 `--dry-run` 路径现在会跳过鉴权前置检查,例如 Token Plan 可在未配置凭据时先打印请求信息。
|
||||
- `--verbose` 的模型请求日志恢复输出请求方法、URL、鉴权来源与响应状态等信息。
|
||||
|
||||
## [1.6.1] - 2026-07-03
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl vision describe` 的示例与 skill 参考文档中的模型 id 由旧版 `qwen-vl-plus` 更新为 `qwen3-vl-plus`,与命令默认模型保持一致。
|
||||
|
||||
## [1.6.0] - 2026-07-02
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl knowledge search` — 基于新版 workspace RAG API 的知识库语义检索。支持 `--query`、`--agent-id`、`--workspace-id`、`--image`(多模态检索,可重复)和 `--query-history`(多轮对话上下文 JSON,用于查询重写)。
|
||||
- `bl knowledge chat` — 知识库 SSE 流式问答。支持 `--message`(可重复,支持 `角色:内容` 前缀传入多轮历史)、`--agent-id`、`--workspace-id` 和 `--image`(多模态)。交互模式下实时展示检索、规划、生成等步骤进度。
|
||||
- `bailian-cli-core` 新增 workspace 级知识 API 类型与端点:`KnowledgeSearchRequest` / `KnowledgeSearchResponse`、`KnowledgeChatRequest` / `KnowledgeChatStreamChunk` / `KnowledgeChatMessage` / `KnowledgeChatContentPart`,以及 `knowledgeSearchEndpoint` / `knowledgeChatEndpoint`。
|
||||
- `kscli` 现已包含 `search` 和 `chat` 命令。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl knowledge retrieve` 描述中已标记为废弃,请改用 `bl knowledge search`。
|
||||
- `kscli` README(中英文)更新,以 `search` 和 `chat` 为主推命令,`retrieve` 标记为废弃。
|
||||
|
||||
## [1.5.0] - 2026-07-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 模型精调 —— `bl finetune`:创建、列出、查询、观察、取消训练任务;拉取训练日志;列出 checkpoint;将 checkpoint 导出为可部署模型;查询训练能力(按模型或按训练类型)。支持 `sft`、`sft-lora`、`dpo`、`dpo-lora`、`cpt` 训练类型。
|
||||
- 模型部署 —— `bl deploy`:创建、列出、查询、更新(限流)、扩缩容、删除部署;列出可部署模型与套餐。
|
||||
- 数据集管理 —— `bl dataset`:上传、列出、查询、删除数据集文件,并新增 `bl dataset validate` 在上传前本地校验 `.jsonl`(ChatML / DPO / CPT 格式)。
|
||||
- Token Plan 管理 —— `bl token-plan`:列出订阅座位、添加成员、批量分配座位、为座位创建 API Key。
|
||||
- 自动更新检查:命令执行完成后,CLI 会(节流地)检查 npm 上是否有新版本并提示 `Update available`;若与稳定版存在大版本差距则自动升级。`--quiet` 或执行 `bl update` 时跳过。
|
||||
- 可组合包:`bailian-cli-runtime`(CLI 框架)与 `bailian-cli-commands`(命令库)现在与 `bailian-cli-core` 一起发布,并在其之上新增了同家族 CLI `knowledge-studio-cli`(`kscli`)。`bl` 行为保持不变。
|
||||
|
||||
### 已移除
|
||||
|
||||
- 移除 `bl config export-schema` 命令(原用于把 CLI 命令导出为 Anthropic/OpenAI 兼容的 JSON tool schema)。
|
||||
|
||||
### 修复
|
||||
|
||||
- 控制台网关类命令(`bl console call` 等)在网关返回非字符串 `errorCode` 时,现在会给出可读的错误信息,而不是 `[object Object]`。
|
||||
|
||||
## [1.4.2] - 2026-06-24
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl omni --list-voices` 无需 API key 即可打印内置输出音色列表(ID、名称、描述、语言)并退出。内置音色表从 6 个扩展到 17 个,新增 Dylan、Sunny、Kiki 等方言音色。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl omni` 默认 `--voice` 改为 `Tina`(原为 `Cherry`)。`--voice` 帮助文案改为指向 `--list-voices`,不再内联列出全部音色。
|
||||
- `bl speech synthesize --list-voices` 输出及缺少 `--voice` 时的提示中,新增官方 CosyVoice 音色文档链接。
|
||||
- Agent skill 配置指引新增 console 站点选择说明(`--console-site domestic` / `international`),适用于 console 登录与网关类命令。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl speech synthesize` 修正 `cosyvoice-v3-flash` 内置音色 ID,由 `longanhuan` 改为 `longanhuan_v3`。
|
||||
|
||||
## [1.4.1] - 2026-06-22
|
||||
|
||||
### 变更
|
||||
|
||||
- 视频生成默认升级到 HappyHorse 1.1 模型,画面质量更佳。如需使用 1.0 模型,可通过 `--model` 指定。
|
||||
- `bl update` 现在会把 agent skill 同步更新到所有 agent 应用(Claude Code、Cursor 等),即使 CLI 已是最新版本也会刷新 skill。
|
||||
|
||||
## [1.4.0] - 2026-06-17
|
||||
|
||||
### 新增
|
||||
|
||||
- 控制台网关支持多 region 与多站点:`cn-beijing` 与 `ap-southeast-1`,各含国内站 / 国际站变体,并新增 `switchAgent` 委托访问。
|
||||
- 新增全局标志 `--console-region`、`--console-site`、`--console-switch-agent`;`bl console call` 另外新增 `--site` 与 `--switch-agent`。
|
||||
- `bl auth login --base-url <url>`:使用 API Key 登录时可指定 base URL。
|
||||
- `bl omni` 新增 `--voice` 选项(Chelsie、Cherry、Ethan、Serena、Sunny、Tina,默认 Cherry)。
|
||||
|
||||
### 变更
|
||||
|
||||
- 所有面向用户的 CLI 文案统一为英文。
|
||||
- `bl advisor recommend` 内部意图 / 排序模型由 `qwen-turbo` 升级为 `qwen-flash`。
|
||||
- 优化 `usage`、`quota`、`workspace` 命令的 JSON 输出。
|
||||
- 配置文件中的 `base_url` 现在优先级高于环境变量 `DASHSCOPE_BASE_URL`。
|
||||
- `bl config show` 现在展示 `config.json` 中的全部字段(敏感值已脱敏)。
|
||||
|
||||
### 移除
|
||||
|
||||
- 移除遗留的 `region` 配置字段及其相关选项。
|
||||
- 清理 `model list` 命令移除后遗留的无效代码。
|
||||
|
||||
### 修复
|
||||
|
||||
- 当控制台会话未登录或已过期时,CLI 现在会给出明确的登录提示,不再是笼统的网关错误。
|
||||
- 修正 `bl video` 命令 `--resolution` / `--ratio` / `--duration` 的帮助文案。
|
||||
|
||||
## [1.3.3] - 2026-06-16
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl knowledge retrieve --help` 现在明确指出 `--api-key` 是推荐的鉴权方式;AK/SK 相关选项已标注废弃并引导用户使用 `--api-key`。
|
||||
|
||||
### 新增
|
||||
|
||||
- 命令定义新增 `notes` 字段 — 命令可以附带上下文说明(鉴权要求、废弃提示等),同时展示在 `--help` 输出和生成的命令手册中。
|
||||
|
||||
## [1.3.2] - 2026-06-12
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 `bl omni --audio` 始终返回 HTTP 400 的问题(#54),音频输入现已能正常理解。
|
||||
|
||||
## [1.3.1] - 2026-06-12
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl` 现在会读取 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` 环境变量(#35)。Node 内置的 `fetch`(undici)默认忽略代理环境变量,导致 VPN 或公司代理下出现 `ECONNRESET`。现已在启动时根据这些变量安装全局代理 dispatcher,并在 `ECONNRESET` 报错提示中给出 `export HTTPS_PROXY=http://127.0.0.1:<port>` 的指引。
|
||||
|
||||
## [1.3.0] - 2026-06-11
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl usage` 命令组:
|
||||
- `bl usage free` — 查询所有模型的免费额度(可通过 `--model` 指定模型)。
|
||||
- `bl usage freetier` — 启用(`--on`)或禁用(`--off`)免费额度模型的自动停服。
|
||||
- `bl usage stats` — 查询模型用量统计(需指定 `--workspace-id`)。
|
||||
- `bl quota` 命令组:
|
||||
- `bl quota list` — 查看模型 RPM/TPM 速率限制(支持 `--model` 过滤,`--all` 展示全部)。
|
||||
- `bl quota check` — 查看当前 RPM/TPM 用量与速率限制。
|
||||
- `bl quota history` — 查看配额变更记录,支持分页。
|
||||
- `bl quota request` — 申请模型临时配额提升。
|
||||
- `bl workspace list` — 列出所有业务空间,包含地域和 endpoint 信息。
|
||||
- `bl knowledge retrieve` 新增 API-Key 鉴权(DashScope 网关),与原有 AK/SK 并存,可用时自动优先使用 API-Key。
|
||||
- 新增检索参数:`--dense-similarity-top-k`、`--sparse-similarity-top-k`、`--rerank-model`、`--rerank-mode`、`--rerank-instruct`,API-Key 与 AK/SK 两条链路均支持。
|
||||
- `bailian-cli-core` 新增 `DashScopeKnowledgeRetrieveRequest` / `DashScopeKnowledgeRetrieveResponse` 类型及 `knowledgeRetrieveEndpoint` 端点。
|
||||
- 知识库检索全面 E2E 测试,覆盖两种鉴权路径、dry-run、rerank 参数及错误场景。
|
||||
|
||||
### 变更
|
||||
|
||||
- 凭据解析优先级:显式 API-Key → 显式 AK/SK flag → 自动检测 API-Key → 回退至配置/环境变量中的 AK/SK。
|
||||
- `--workspace-id` 仅在 AK/SK 鉴权时必填,API-Key 模式下不再强制要求。
|
||||
- `--top-k` 标记为废弃,改用 `--rerank-top-n`;使用时输出警告并自动映射。
|
||||
- `--access-key-id` / `--access-key-secret` 标记为废弃(推荐使用 API-Key)。
|
||||
- 全部文档中的 API Key 和控制台链接更新为直达密钥管理页面。
|
||||
|
||||
### 修复
|
||||
|
||||
- AK/SK 链路 `--rerank` 现在正确设置 `EnableReranking`,而非之前无效的 `Rerank: true` 布尔值。
|
||||
|
||||
## [1.2.1] - 2026-06-09
|
||||
|
||||
### 变更
|
||||
|
||||
+2
-7
@@ -6,7 +6,8 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- Node.js ≥ 22.12 (required for developing this repo)
|
||||
- End users installing the published CLI only need Node.js >= 18.17
|
||||
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
|
||||
- A DashScope API key for running e2e tests
|
||||
|
||||
@@ -33,13 +34,7 @@ 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"
|
||||
|
||||
+3
-8
@@ -6,8 +6,9 @@
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 参与本仓库开发:Node.js ≥ 22.12
|
||||
- 仅安装/使用已发布的 CLI:Node.js >= 18.17
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 跑 e2e 需要一个百炼 API Key
|
||||
|
||||
## 仓库结构
|
||||
@@ -33,13 +34,7 @@ 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 "一只走路的猫"
|
||||
|
||||
+16
-8
@@ -7,7 +7,7 @@
|
||||
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求,Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
|
||||
|
||||
1. **Node.js**
|
||||
- 要求 **≥ 22.12.0**,执行 `node -v` 确认。
|
||||
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
|
||||
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
|
||||
|
||||
2. **npm**(**唯一**允许的包管理器)
|
||||
@@ -69,7 +69,7 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
|
||||
|
||||
### 备选:由 Agent 引导用户输入 API Key 后登录
|
||||
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
|
||||
|
||||
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
|
||||
|
||||
@@ -80,6 +80,15 @@ npx skills add modelstudioai/cli --all -g
|
||||
3. 用户提供了 Key 之后,在**用户本机终端**执行(Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
|
||||
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
|
||||
|
||||
### 备选二:使用 Token Plan API Key
|
||||
|
||||
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
|
||||
|
||||
1. 请用户从订阅详情页获取或复制 Token Plan API Key,勿要求用户发到公开渠道。
|
||||
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`。
|
||||
3. `token-plan` Profile 已内置默认 Base URL;登录命令会先测试 Key,通过后才保存并激活该 Profile,无需另行配置或重复测试。
|
||||
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
|
||||
|
||||
### 其他方式
|
||||
|
||||
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
|
||||
@@ -93,16 +102,15 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
---
|
||||
|
||||
## 4. 最小功能验证
|
||||
## 4. 配置验证
|
||||
|
||||
在鉴权配置完成后执行:
|
||||
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
|
||||
|
||||
```bash
|
||||
bl auth status --output json
|
||||
bl text chat --message "ping" --non-interactive --output json
|
||||
```
|
||||
|
||||
若失败:根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、region 等)。全局 region:`--region cn|us|intl`,默认 `cn`。
|
||||
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +119,7 @@ bl text chat --message "ping" --non-interactive --output json
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ----------------------- | -------------------- | --------------------------------------------------------------- |
|
||||
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 22.12 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 引导用户更新 Key 并 `bl auth login --api-key` |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
|
||||
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
|
||||
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,15 +27,20 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **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)
|
||||
- **Video generation & editing** — happyhorse-1.1 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
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
- **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
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
@@ -51,7 +56,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
@@ -64,7 +69,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
@@ -76,7 +81,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -87,6 +92,12 @@ bl auth login --console
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
@@ -108,9 +119,32 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
# Browser login (required for console capability commands)
|
||||
bl auth login --console
|
||||
|
||||
# Browse apps / free-tier quota
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -119,7 +153,7 @@ bl usage free --model qwen3-max
|
||||
|
||||
### DashScope API Key
|
||||
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cli?source_channel=key_github&).
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
@@ -132,21 +166,34 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud AK/SK (Knowledge Base only)
|
||||
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
|
||||
|
||||
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Required for the `token-plan` command group. 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
|
||||
# Option 1: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# Option 2: Environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
@@ -159,7 +206,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
@@ -171,14 +218,15 @@ Config file location: `~/.bailian/config.json`
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
| :--------------------------- | :---------------------------------------------------------------- |
|
||||
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| 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/cli?source_channel=key_github& |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| Resource | URL |
|
||||
| :--------------------------- | :---------------------------------------------------------------------------------------- |
|
||||
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| 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/?source_channel=cli_github |
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
+74
-23
@@ -5,11 +5,11 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,15 +27,20 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`)
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
@@ -51,7 +56,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
@@ -62,7 +67,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
@@ -74,14 +79,23 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
@@ -103,9 +117,32 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
bl auth login --console
|
||||
|
||||
# 浏览应用 / 免费额度
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -114,7 +151,7 @@ bl usage free --model qwen3-max
|
||||
|
||||
### DashScope API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
@@ -127,21 +164,34 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`app list`、`usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 AK/SK(仅知识库检索)
|
||||
### 阿里云 OpenAPI AK/SK(仅 Token Plan)
|
||||
|
||||
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
```bash
|
||||
# 方式一:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# 方式二:环境变量
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
@@ -154,7 +204,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
@@ -166,14 +216,15 @@ bl update
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| 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/cli?source_channel=key_github& |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
|
||||
+96
-68
@@ -2,117 +2,145 @@
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 增加新的鉴权方式(OAuth、SSO、控制台回调登录)
|
||||
- 增加新的 token 来源(env / config / flag / 文件)
|
||||
- 调整凭证解析优先级
|
||||
- 改 `bl auth login` 流程
|
||||
- 增加新的鉴权域或 token 来源(env / config / flag / 文件)
|
||||
- 调整 API Key / Console token 解析优先级
|
||||
- 改 `bl auth login` / `auth status` / `auth logout` 流程
|
||||
- 改 runtime 对 command `auth` 的 gating 或 credential 注入
|
||||
|
||||
## 鉴权链路
|
||||
|
||||
```
|
||||
flag 优先 ─→ config 文件 ─→ env var
|
||||
│ │ │
|
||||
└──── resolveCredential() (core) ───┐
|
||||
│
|
||||
▼
|
||||
cli/utils/ensure-key.ts (启动时拦)
|
||||
命令注入 Authorization 头
|
||||
argv flags ─┐
|
||||
env var ──┼─ buildSources(flags) ─┐
|
||||
config ──┘ │
|
||||
├─ buildSettings(sources) → ctx.settings
|
||||
│
|
||||
├─ resolveApiKey(sources) → model-domain Client
|
||||
├─ resolveConsole(sources) → console-domain Client
|
||||
└─ resolveOpenApi(sources) → OpenAPI Client
|
||||
|
||||
defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx)
|
||||
```
|
||||
|
||||
凭证类型(`AuthMethod`):
|
||||
当前 command 鉴权域(`AuthRequirement`):
|
||||
|
||||
- `api-key` — DashScope SK(`sk-...`),走 Bearer 头
|
||||
- `access-token` — 控制台 OAuth 回调拿到的临时 token,走 Bearer + 不同 endpoint
|
||||
- `ak/sk` — Alibaba Cloud 标准 AK/SK,走 ROA 签名(只用于知识库)
|
||||
- `apiKey` — DashScope / OpenAI-compatible 模型域,用 API key 与 model base URL
|
||||
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent/workspace
|
||||
- `openapi` — 阿里云 OpenAPI 签名域,用 AccessKey ID/Secret 调用 Token Plan 等 OpenAPI
|
||||
- `none` — 本地命令、登录/配置类命令、无需 credential 的命令
|
||||
|
||||
### 双凭证并存(API Key + Console)
|
||||
### 多凭证并存
|
||||
|
||||
`~/.bailian/config.json` 可同时保存 `api_key` 与 `access_token`。**登录任一种方式不得删除另一种**(`bl auth login --api-key` / `--console` 只更新对应字段)。
|
||||
`~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种:
|
||||
|
||||
- `bl auth login --api-key ...` 只更新 `api_key` / `base_url`
|
||||
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
|
||||
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
|
||||
- `bl auth logout --console` 只清 `access_token`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
|
||||
- `bl auth logout` 清 `api_key` + `base_url` + `access_token` + `access_key_*`
|
||||
|
||||
解析分工:
|
||||
|
||||
- `resolveCredential()` — DashScope API 命令(`text chat`、`file upload` 等);config 里两者都有时 **优先 `api_key`**
|
||||
- `resolveConsoleGatewayCredential()` — 控制台网关(`app list`、`usage free`、`console call`);**只用** env/file 的 `access_token`,忽略 `api_key`
|
||||
- `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment)
|
||||
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
|
||||
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile;未传
|
||||
`--config` 时继续写当前激活项,失败和 dry-run 不切换
|
||||
- `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
|
||||
- `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
|
||||
- `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照
|
||||
|
||||
必改调用点: 凡 `callConsoleGateway` 必须用 `resolveConsoleGatewayCredential`,不能误用 `resolveCredential`(否则 config 仅有 api_key 时会拿 sk- 打网关)。
|
||||
|
||||
`bl auth logout --console` 只清 `access_token`;全量 `bl auth logout` 清两者。
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. core 层(类型 + 解析)
|
||||
|
||||
- [ ] `packages/core/src/types/command.ts`:
|
||||
- 如新增鉴权域,扩展 `AuthRequirement`
|
||||
- 更新 `credentialFlagDefs()` 暴露该域可见的 flag
|
||||
- 必要时新增 `*_AUTH_FLAGS`
|
||||
- [ ] `packages/core/src/auth/types.ts`:
|
||||
- 新增 `AuthMethod` 字面量
|
||||
- 新增 `ResolvedCredential` 字段(如 token 类型 / 过期时间)
|
||||
- 新增 credential 类型 / source / scope 字段
|
||||
- [ ] `packages/core/src/auth/resolver.ts`:
|
||||
- `resolveCredential()` 增加新分支
|
||||
- 控制台网关命令用 `resolveConsoleGatewayCredential()`(与 DashScope 解析分离)
|
||||
- 优先级注释保持清晰(数字标号)
|
||||
- [ ] `packages/core/src/auth/credentials.ts`:
|
||||
- 如果新方式需要持久化,加 `save*` / `load*` / `clear*`
|
||||
- 新增或调整 resolver,保持优先级注释清晰
|
||||
- 新增/调整 resolver hint 时保持产品无关,不要新增 `bl` / `kscli` 硬编码;当前遗留的 `bl auth login` hint 如被触碰,迁到 runtime `enhanceHint`
|
||||
- [ ] `packages/core/src/auth/store.ts`:
|
||||
- 如果新方式需要持久化,扩展 `AuthStore` / `AuthPersistPatch`
|
||||
- [ ] `packages/core/src/config/schema.ts`:
|
||||
- `Config` 接口加新字段(如 `fileAccessToken`、`accessTokenEnv`)
|
||||
- `ConfigFile` 接口加对应 disk 字段(snake_case)
|
||||
- `ConfigFile` 加 disk 字段(snake_case)
|
||||
- `Settings` 加运行时字段(如果命令需要读取)
|
||||
- [ ] `packages/core/src/config/loader.ts`:
|
||||
- `loadConfig()` 把 env / 文件读到 Config 上
|
||||
- `buildSources()` / `buildSettings()` 把 flag/env/file 读到正确层
|
||||
|
||||
### B. core 客户端
|
||||
### B. runtime 层
|
||||
|
||||
- [ ] `packages/core/src/client/http.ts`:
|
||||
- 不同 `credential.method` 走不同分支(参考已有 `access-token` 分支走 console gateway)
|
||||
- Authorization 头注入正确
|
||||
- [ ] `packages/runtime/src/create-cli.ts`:
|
||||
- parse flags 时纳入新的全局/凭证域 flag
|
||||
- `globalFlags` 与 `ownFlags` 分流正确
|
||||
- [ ] `packages/runtime/src/middleware.ts:authStage`:
|
||||
- 根据 `command.auth` 解析 credential 并注入 `ctx.client`
|
||||
- `settings.dryRun` 下是否允许缺 credential 的策略明确
|
||||
- [ ] `packages/runtime/src/error-handler.ts`:
|
||||
- AUTH hint 增强使用 `binName`,不要硬编码 `bl`
|
||||
- URL 从 `packages/runtime/src/urls.ts` import
|
||||
|
||||
### C. cli 层
|
||||
### C. command 层
|
||||
|
||||
- [ ] `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
|
||||
- [ ] `packages/commands/src/commands/auth/login.ts`:
|
||||
- 新增/调整登录 flag 与流程
|
||||
- 持久化只走 `ctx.authStore.login(...)`
|
||||
- [ ] `packages/commands/src/commands/auth/status.ts`:
|
||||
- 分别显示 model / console / openapi 鉴权状态,并 mask token
|
||||
- [ ] `packages/commands/src/commands/auth/logout.ts`:
|
||||
- 清理范围与双凭证并存规则一致
|
||||
- [ ] 新的业务命令设置正确 `auth`:
|
||||
- 模型域请求 → `auth: "apiKey"`
|
||||
- Console Gateway → `auth: "console"`
|
||||
- 阿里云 OpenAPI 请求 → `auth: "openapi"`
|
||||
- 本地/登录/配置 → `auth: "none"`
|
||||
|
||||
### 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. 用户面文档
|
||||
### D. 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README.zh.md` "Authentication" 段落
|
||||
- [ ] `skills/bailian-cli/reference/` 通过 `pnpm run sync:skill-assets` 重建
|
||||
|
||||
### G. 测试
|
||||
### E. 测试
|
||||
|
||||
- [ ] `packages/cli/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径
|
||||
- [ ] mask token 的输出格式不变(避免泄漏)
|
||||
- [ ] 如调整 resolver 优先级,补 core/runtime 单测覆盖 flag > env > file
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
# 各种凭证组合
|
||||
unset DASHSCOPE_API_KEY DASHSCOPE_ACCESS_TOKEN
|
||||
HOME=/tmp/empty node packages/cli/src/main.ts auth status
|
||||
unset DASHSCOPE_API_KEY ALIBABA_CLOUD_ACCESS_KEY_ID ALIBABA_CLOUD_ACCESS_KEY_SECRET
|
||||
HOME=/tmp/empty pnpm -F bailian-cli exec tsx src/main.ts auth status
|
||||
|
||||
# flag 注入
|
||||
node packages/cli/src/main.ts auth status --api-key sk-xxx
|
||||
# flag 注入(凭证域 flag 只在对应业务命令可见,auth status 不接收)
|
||||
pnpm -F bailian-cli exec tsx src/main.ts text chat --message hi --api-key sk-xxx --dry-run
|
||||
pnpm -F bailian-cli exec tsx src/main.ts token-plan list-seats --access-key-id ak-xxx --access-key-secret sec-xxx --dry-run
|
||||
pnpm -F bailian-cli exec tsx src/main.ts auth login --open-api --access-key-id ak-xxx --access-key-secret sec-xxx --dry-run
|
||||
|
||||
# env 注入
|
||||
DASHSCOPE_ACCESS_TOKEN=xxx node packages/cli/src/main.ts auth status
|
||||
DASHSCOPE_API_KEY=sk-xxx pnpm -F bailian-cli exec tsx src/main.ts auth status
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID=ak-xxx ALIBABA_CLOUD_ACCESS_KEY_SECRET=sec-xxx pnpm -F bailian-cli exec tsx src/main.ts auth status
|
||||
```
|
||||
|
||||
Console 登录/网关相关改动:
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli exec tsx src/main.ts auth login --console
|
||||
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 加了新 token 来源但忘了改 `resolveCredential` 优先级,实际不生效
|
||||
- ✗ `Config` 加字段但 `loadConfig` 没读 → 字段永远 undefined
|
||||
- ✗ `bl auth login` 写成功但 `bl auth status` 不识别(两边走的 storage path 不一致)
|
||||
- ✗ 加了新 token 来源但忘了改 resolver 优先级,实际不生效
|
||||
- ✗ `ConfigFile` / `Settings` 加字段但 `parseConfigFile` 或 `buildSettings` 没读
|
||||
- ✗ `auth login` 写成功但 `auth status` 不识别(两边走的 storage path 不一致)
|
||||
- ✗ token mask 显示完整 token,日志泄漏
|
||||
- ✗ `auth: "console"` 命令误用 `apiKey` 域,config 只有 API key 时会把 `sk-...` 发到网关
|
||||
- ✗ 新增 core resolver hint 时写死产品命令,导致 `kscli` 等入口提示错误
|
||||
|
||||
@@ -50,14 +50,14 @@ git diff --name-only <base>...<head>
|
||||
- [ ] **`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` 三个全局表新增项不和现有项冲突
|
||||
- [ ] **全局表没冲突**:`packages/cli/src/commands.ts` / `packages/kscli/src/main.ts` command map、`defineCommand({ auth })`、`GLOBAL_FLAGS` / `MODEL_AUTH_FLAGS` / `CONSOLE_AUTH_FLAGS` / `OPENAPI_AUTH_FLAGS`、`ExitCode` 新增项不和现有项冲突
|
||||
|
||||
## 清单 B:用户透出(用户可见的新东西必看)
|
||||
|
||||
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
|
||||
- [README.md](README.md) + [README.zh.md](README.zh.md)(中英文都要,常漏 `_CN`)
|
||||
- (SKILL.md 已迁出本仓库,由 `npx add skills` 机制独立维护,不在本仓库 review 范围)
|
||||
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` / `apiDocs` 都填了
|
||||
- `skills/bailian-cli/reference/` + `skills/bailian-cli/SKILL.md` 通过 `pnpm run sync:skill-assets` 更新并提交
|
||||
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` 都填了
|
||||
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例
|
||||
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
|
||||
- [ ] **错误信息 / 提示文案**:面向用户的字符串通顺、双语(项目主体是中文场景)
|
||||
@@ -80,7 +80,7 @@ git diff --name-only <base>...<head>
|
||||
解冲突要点(merge 时不要漏):
|
||||
- <冲突文件> + <字段/段落> + <怎么取舍>
|
||||
↑ 放"合并那一刻才会出现"的细节,例如 package.json 的 files/scripts/devDependencies 各取并集、
|
||||
NO_AUTH_SETUP 这种全局表两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
|
||||
command map / `auth` / 全局 flags 这类元数据两边都加项时不要丢一侧、pnpm-lock.yaml 直接 rm 后 pnpm install 重生等。
|
||||
建议修(可后置):
|
||||
- ...
|
||||
仅信息(无需动作,告知即可):
|
||||
@@ -94,11 +94,11 @@ git diff --name-only <base>...<head>
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
|
||||
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
|
||||
| `registry.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
|
||||
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
|
||||
| `NO_AUTH_SETUP` 加了不该免登录的命令 | 安全风险,用户没登录也能调付费 API |
|
||||
| `NO_AUTH_SETUP` / `registry.ts` 这类全局表两边都加项,解冲突时被合掉一侧 | 某个命令突然要求登录 / 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
| 漏点 | 后果 |
|
||||
| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `pnpm-workspace.yaml` 把 `packages/*` 收窄成显式列表 | 合并后目标分支的新子包不再被 workspace 识别,`pnpm install` 看似正常但子包失联 |
|
||||
| 源分支 version 比目标分支低,直接 merge 覆盖 | npm 上版本号回退,latest tag 错乱 |
|
||||
| `packages/cli/src/commands.ts` 注册新命令但忘了 [README](README.md) / [README.zh](README.zh.md) | 用户完全感知不到新功能 |
|
||||
| 共享 util 重构(抽公共函数)只改了一处调用方 | 其它调用方静默走旧分支,行为分裂 |
|
||||
| 命令 `auth` 域设错(如 Console Gateway 用了 `apiKey`) | 凭证域 flag/help/credential 注入都错,运行期才暴露 |
|
||||
| `packages/cli/src/commands.ts` / `packages/kscli/src/main.ts` 这类 map 两边都加项,解冲突时被合掉一侧 | 某个新命令注册丢失,编译能过、回归不易察觉 |
|
||||
|
||||
@@ -81,11 +81,12 @@ git merge-base --is-ancestor 12f2b1b 3fc54ae && echo "IN" || echo "NOT IN"
|
||||
光看 commit 还不够,要确认目标功能的代码 / 文件在 release commit 上真的存在:
|
||||
|
||||
```sh
|
||||
# 列出 release commit 下某目录的文件
|
||||
git ls-tree -r <releaseCommit> --name-only -- packages/cli/src/commands/
|
||||
# 列出 release commit 下命令实现与产品入口
|
||||
git ls-tree -r <releaseCommit> --name-only -- packages/commands/src/commands/
|
||||
git show <releaseCommit>:packages/cli/src/commands.ts | head
|
||||
|
||||
# 看 release commit 下某文件的内容
|
||||
git show <releaseCommit>:packages/cli/src/commands/console/call.ts | head
|
||||
git show <releaseCommit>:packages/commands/src/commands/console/call.ts | head
|
||||
```
|
||||
|
||||
特别注意被一行带过的"杂项" commit。本仓库历史踩过坑:`feat(cli): enhance output options and add new commands` 这种标题里藏了**新命令** + **新输出格式** + **logout 增强**三件事,粗看会全部漏掉。
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
# CLI E2E 测试规范
|
||||
|
||||
## 架构分层
|
||||
|
||||
| 层级 | 路径 | 测什么 |
|
||||
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup(`private`,不发布) |
|
||||
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、live(gated);每用例最小路由 |
|
||||
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
|
||||
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组;identity(`--version`、`search --help` path) |
|
||||
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
|
||||
|
||||
**依赖边界**:`e2e` → `core`;`commands/tests` → `e2e` + `commands/src`;产品 tests → `e2e` + 各自 `src`。**禁止**产品 import `commands/tests/**`(子进程 spawn harness 路径除外)。
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 新增/修改 `packages/cli/src` 下的 command(`commands/catalog.ts` 登记、`defineCommand` 实现、options/usage)
|
||||
- 新建或扩展 `packages/cli/tests/e2e/*.e2e.test.ts` 用例
|
||||
- 为命令补 help / 缺参 / dry-run / 真实集成测试
|
||||
- 新增/修改 `packages/commands/src/commands` 下的 command 实现
|
||||
- 新增/修改 `packages/cli/src/commands.ts` 的 `bl` 命令路径 map
|
||||
- 新建或扩展 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
|
||||
- 新增 bl 产品 path → `registry.smoke` 自动覆盖 leaf path;commands topic 测试在 `topic-routes.ts` 补最小路由
|
||||
|
||||
以上情况必须同步维护 `packages/cli/tests/e2e/<topic>.e2e.test.ts`。跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。
|
||||
跑测与环境变量见 `.cursor/skills/bailian-cli-e2e/SKILL.md`。
|
||||
|
||||
> **规则**:共享 command 行为在 `commands/tests/e2e`;产品 map、identity、CLI-only 命令留在对应产品 `tests/e2e`。
|
||||
|
||||
## 文件与工具
|
||||
|
||||
- 路径:`packages/cli/tests/e2e/<kebab-topic>.e2e.test.ts`
|
||||
- 框架:`vite-plus/test`;子进程跑 CLI:`runCli` from `./helpers.ts`
|
||||
### commands E2E
|
||||
|
||||
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
|
||||
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`(spawn `harness/main.ts`,`routes` 为本 topic 最小 path → export 映射)
|
||||
- fixtures:`packages/commands/tests/e2e/fixtures/`
|
||||
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map)
|
||||
|
||||
### 产品 smoke
|
||||
|
||||
- bl:`runCli` from `packages/cli/tests/e2e/helpers.ts`
|
||||
- kscli:`runKscli` from `packages/kscli/tests/e2e/helpers.ts`
|
||||
|
||||
### 共享
|
||||
|
||||
- gating / output / runner:`e2e/gating`、`e2e/output`、`e2e/runner`
|
||||
- globalSetup:根 `vite.config.ts` → `packages/e2e/src/global-setup.ts`
|
||||
- 解析 JSON stdout:`parseStdoutJson`;输出目录:`makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url))`
|
||||
- 长任务:`cliTimeoutPrefix()`;视频用例加 `test(..., 3_600_000)` 等显式超时
|
||||
|
||||
## 双层 describe(固定结构)
|
||||
|
||||
```ts
|
||||
// 1) 不 skip:分组 + --help,无密钥、无真实 API
|
||||
// 1) 不 skip:--help,无密钥、无真实 API(分组 help 由 bl registry.smoke 覆盖)
|
||||
describe("e2e: <topic>", () => {
|
||||
test("<group> 分组展示子命令帮助且成功退出", ...);
|
||||
test("<subcommand> --help 正常退出", ...);
|
||||
});
|
||||
|
||||
// 2) skipIf:缺参 / dry-run / 真实集成;原有集成用例放最后、勿改逻辑
|
||||
// 2) skipIf:缺参 / dry-run / 真实集成
|
||||
describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
test("缺少 --<flag> 时退出为用法错误 (2)", ...);
|
||||
test("<cmd> --dry-run ...", ...); // 若适用
|
||||
@@ -32,57 +60,74 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
});
|
||||
```
|
||||
|
||||
## skip 条件(helpers.ts)
|
||||
## skip 条件(`e2e/gating`,commands helpers re-export)
|
||||
|
||||
| 场景 | 条件 |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
|
||||
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
|
||||
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
|
||||
| 知识库 | `isKnowledgeE2EReady()` |
|
||||
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
|
||||
| 场景 | 条件 |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
|
||||
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
|
||||
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
|
||||
| OpenAPI AK/SK | `isOpenApiE2EReady()`(`.env` 中必须同时提供完整 AK/SK) |
|
||||
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
|
||||
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()`(`knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent 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 块**末尾**
|
||||
1. **--help**:`runCommandE2e(ROUTES, [..., "--help"])` → stderr 含主要 flags
|
||||
2. **缺参**:带无害全局 flag(如 `--quiet`)且不传 required flag → `exitCode === 2`
|
||||
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
|
||||
4. **真实集成**:放在 skip 块**末尾**
|
||||
|
||||
## 增删命令同步
|
||||
|
||||
- **commands export** + **topic 路由**(`topic-routes.ts` 或测试文件内 `ROUTES`)+ **产品 map**(`cli/commands.ts` / `kscli/commands.ts`)
|
||||
- 分组 help 由产品 `registry.smoke` 负责,无需在 commands 重复
|
||||
|
||||
## 安全与例外
|
||||
|
||||
- **禁止真实破坏性操作**:`auth logout` 只用 `--dry-run`;`config set` 只用 `--dry-run`
|
||||
- **禁止破坏真实用户配置**:`auth logout` 默认只用 `--dry-run`;需要验证实际落盘时,必须通过
|
||||
`BAILIAN_CONFIG_DIR` 指向隔离 fixture;`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` 更新 `skills/bailian-cli/reference/` 并提交
|
||||
- [ ] 顶层:分组 help + 子命令 `--help`(多子命令则各一条 help)
|
||||
- [ ] `packages/commands/src/index.ts` 导出 + `packages/cli/src/commands.ts` 暴露路径 + `topic-routes.ts` 补最小路由
|
||||
- [ ] `packages/commands/tests/e2e/<topic>.e2e.test.ts`(新建或扩展)
|
||||
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/` 并提交
|
||||
- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖)
|
||||
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
|
||||
- [ ] 至少一条真实集成(或说明为何仅 smoke);不破坏已有集成用例顺序
|
||||
- [ ] `pnpm test packages/cli/tests/e2e/<file>` 通过
|
||||
- [ ] `vp test packages/commands/tests/e2e/<file>` 通过
|
||||
|
||||
## 调试命令
|
||||
|
||||
```sh
|
||||
pnpm --filter bailian-cli-commands exec vp test packages/commands/tests/e2e/text-chat.e2e.test.ts
|
||||
pnpm --filter bailian-cli exec vp test packages/cli/tests/e2e/registry.smoke.e2e.test.ts
|
||||
pnpm --filter knowledge-studio-cli exec vp test packages/kscli/tests/e2e/registry.smoke.e2e.test.ts
|
||||
pnpm --filter bailian-cli-runtime exec vp test packages/runtime/tests/proxy.e2e.test.ts
|
||||
```
|
||||
|
||||
## 示例片段
|
||||
|
||||
```ts
|
||||
import { FOO_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
test("foo bar 缺少 --prompt 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["foo", "bar", "--non-interactive"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(FOO_ROUTES, ["foo", "bar", "--quiet"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--prompt|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("foo bar --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(FOO_ROUTES, [
|
||||
"foo",
|
||||
"bar",
|
||||
"--dry-run",
|
||||
"--prompt",
|
||||
"x",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
@@ -97,4 +142,4 @@ test("foo bar --dry-run 仅输出计划", async () => {
|
||||
- **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)。
|
||||
勿把压测并入 E2E 或默认 CI。详见 [stress-batch-tests.md](stress-batch-tests.md).
|
||||
|
||||
@@ -5,90 +5,129 @@
|
||||
- 增加新的 `bl xxx` 命令
|
||||
- 删除已有命令
|
||||
- 重命名命令(包括从单级 `bl x` 改成 `bl x y` 或反向)
|
||||
- 调整某个 shared command 在 `bl` / `kscli` 等产品入口里的暴露路径
|
||||
|
||||
## 命令路径与文件路径的对应规则
|
||||
## 命令实现与产品路径的关系
|
||||
|
||||
命令实现住在 `packages/commands`,产品路径由入口包决定。实现文件路径按能力组织,但不再等同于最终命令路径。
|
||||
|
||||
```
|
||||
单级命令(无 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 时合理(否则拍平到两级)
|
||||
实现文件:
|
||||
packages/commands/src/commands/knowledge/retrieve.ts
|
||||
↓ packages/commands/src/index.ts export { default as knowledgeRetrieve }
|
||||
产品入口:
|
||||
packages/cli/src/commands.ts "knowledge retrieve": knowledgeRetrieve ↔ bl knowledge retrieve
|
||||
packages/kscli/src/main.ts "retrieve": knowledgeRetrieve ↔ kscli retrieve
|
||||
```
|
||||
|
||||
文件路径与命令路径必须 1:1 对齐。
|
||||
常见路径形态:
|
||||
|
||||
```
|
||||
单级命令: packages/commands/src/commands/update.ts ↔ bl update
|
||||
两级命令: packages/commands/src/commands/text/chat.ts ↔ bl text chat
|
||||
子组命令: packages/commands/src/commands/memory/profile-get.ts ↔ bl memory profile get
|
||||
```
|
||||
|
||||
子组要慎用:只有子组下有 ≥2 个 action 时才合理,否则优先拍平到两级。
|
||||
|
||||
## CLI 命令注册架构(必读)
|
||||
|
||||
命令元数据以 **`catalog.ts` 为单一登记处**;`registry.ts` 只负责解析与打印 help,不再内嵌命令表或手写 Resources 列表。
|
||||
`packages/commands` 是命令库,只导出单个 command;不内置 path presets,不关心 `bl` / `kscli`。每个产品入口传入自己的 command map,`runtime` 负责解析、help、鉴权、遥测、执行。
|
||||
|
||||
```
|
||||
commands/<...>.ts defineCommand({ name, description, usage, options, examples, apiDocs?, run })
|
||||
packages/commands/src/commands/<...>.ts
|
||||
defineCommand({ auth, flags, usageArgs, exampleArgs, validate, run })
|
||||
↓
|
||||
commands/catalog.ts export const commands: Record<string, Command>
|
||||
packages/commands/src/index.ts
|
||||
export { default as xxxCommand } from "./commands/...ts"
|
||||
↓
|
||||
┌────┴────┬──────────────────────┬─────────────────────┐
|
||||
↓ ↓ ↓ ↓
|
||||
registry.ts main.ts tools/generate-reference.ts export-schema.ts
|
||||
(解析/help) (入口) → skills/bailian-cli/reference/index.md + <group>.md
|
||||
┌──────────────────────────────┬──────────────────────────────┐
|
||||
│ packages/cli/src/commands.ts │ packages/kscli/src/main.ts │
|
||||
│ { "text chat": textChat } │ { "retrieve": knowledge... } │
|
||||
└──────────────┬───────────────┴──────────────┬───────────────┘
|
||||
↓ ↓
|
||||
createCli(commands, identity) → runtime registry/help/middleware
|
||||
↓
|
||||
tools/generate-reference.ts reads packages/cli/src/commands.ts
|
||||
```
|
||||
|
||||
- **`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`**: pre-commit / `pnpm run sync:skill-assets` 时读 `catalog.ts`,写 `skills/bailian-cli/reference/index.md`(索引) + `skills/bailian-cli/reference/<一级命令>.md`(详情,勿手改)。该目录**纳入 git**,随 `npx skills add modelstudioai/cli` 分发
|
||||
- **`packages/commands/src/commands/<...>.ts`**:命令实现;`usageArgs` / `exampleArgs` 只写参数片段,不写 `bl` / `kscli` 前缀
|
||||
- **`packages/commands/src/index.ts`**:导出命令实现;新增命令必须在这里 re-export
|
||||
- **`packages/cli/src/commands.ts`**:`bl` 产品命令 map;新增/删除/重命名 `bl` 命令必须改这里
|
||||
- **`packages/kscli/src/main.ts`**:`kscli` 产品命令 map;只有该入口需要暴露/变更时才改
|
||||
- **`packages/runtime/src/registry.ts`**:通用 registry,从传入 map 建树;不要在这里登记业务命令
|
||||
- **`tools/generate-reference.ts`**:pre-commit / `pnpm run sync:skill-assets` 时读 `packages/cli/src/commands.ts`,写 `skills/bailian-cli/reference/index.md` + `<一级命令>.md`。该目录**纳入 git**,勿手改
|
||||
|
||||
已删除、勿再引用:`commands/help.ts`、`registry.ts` 内联 `new CommandRegistry({...})`、`printRootHelp` 手写命令行。
|
||||
已删除/勿再引用:旧的 `packages/cli/src/commands/catalog.ts`、旧的 `packages/cli/src/commands/index.ts` catalog re-export、`packages/cli/src/registry.ts`、`skipDefaultApiKeySetup`、`ensureApiKey` 启动拦截、`config/export-schema.ts`。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. 代码层
|
||||
### 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 以免循环依赖
|
||||
- [ ] 新建/删除/移动对应的 `packages/commands/src/commands/<...>.ts`
|
||||
- [ ] `defineCommand` 字段使用当前 schema:
|
||||
- `auth: "apiKey" | "console" | "openapi" | "none"`
|
||||
- `flags`(camelCase key,由 runtime 渲染为 kebab-case)
|
||||
- `usageArgs`(不含 bin/path 前缀)
|
||||
- `exampleArgs`(不含 bin/path 前缀)
|
||||
- `validate`(跨 flag 校验)
|
||||
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
|
||||
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
|
||||
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
|
||||
- [ ] `packages/commands/src/index.ts`:新增或移除对应 export
|
||||
- [ ] 如果命令调用 Console Gateway,设置 `auth: "console"`;不要重复声明 console 凭证域 flags
|
||||
- [ ] 如果命令不需要网络或自己管理配置/登录,设置 `auth: "none"`;不要绕过 runtime auth stage
|
||||
|
||||
### B. 文档层
|
||||
### B. 产品入口
|
||||
|
||||
- [ ] `packages/cli/src/commands.ts`:按需增删 `import` 与 `commands` map key
|
||||
- [ ] 新 map key 就是 `bl` 下的命令路径;重命名时全仓 grep 旧路径字符串
|
||||
- [ ] 如果 `kscli` 入口也要暴露/移除该能力,同步 `packages/kscli/src/main.ts`
|
||||
- [ ] 不要在 `packages/runtime/src/registry.ts` 或 `create-cli.ts` 里写业务命令表
|
||||
|
||||
### C. 文档层
|
||||
|
||||
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新 `skills/bailian-cli/reference/` 与 `SKILL.md` 的 `metadata.version` 并提交
|
||||
- [ ] `README.md` / `README.zh.md`: Quick Start、命令一览(用户向,与 help 对齐即可)
|
||||
- [ ] `skills/bailian-cli/SKILL.md`: 若安装说明或能力边界有变,同步更新
|
||||
- [ ] `README.md` / `README.zh.md`:Quick Start、命令一览、认证说明(用户向,与 help 对齐)
|
||||
- [ ] `skills/bailian-cli/SKILL.md`:若安装说明或能力边界有变,同步更新
|
||||
|
||||
### C. 测试层
|
||||
### D. 测试层
|
||||
|
||||
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/cli/tests/e2e/<topic>.e2e.test.ts`
|
||||
- [ ] 删除命令时一并删对应 e2e
|
||||
- [ ] 按 [cli-e2e-tests.md](cli-e2e-tests.md) 新建或更新 `packages/commands/tests/e2e/<topic>.e2e.test.ts`
|
||||
- [ ] 同步 `packages/commands/tests/e2e/topic-routes.ts`(该 topic 的最小 path → export 映射)
|
||||
- [ ] bl 产品 path 变更由 `registry.smoke` 自动覆盖;kscli 变更同步 `kscli/src/commands.ts` 与 `registry.smoke`
|
||||
- [ ] 删除命令时一并删对应 commands e2e / README 示例 / reference / topic 路由条目
|
||||
- [ ] 如果 shared command 在不同入口路径下复用,至少确保 commands e2e 覆盖 `bl` path;`kscli` 入口改动需补对应 smoke 或说明不测 flat path live
|
||||
|
||||
### D. 重命名特殊处理
|
||||
### E. 重命名特殊处理
|
||||
|
||||
- [ ] 全仓 grep **旧命令名字符串**,确保以下位置全部更新:
|
||||
- `catalog.ts` 的 key
|
||||
- error hints(cli 层)
|
||||
- `packages/cli/src/commands.ts` map key
|
||||
- `packages/kscli/src/commands.ts` map key(如适用)
|
||||
- 用户可见 hint / README / tests
|
||||
- `skills/bailian-cli/reference/`(重建后检查并提交)
|
||||
- README 示例
|
||||
- 测试断言
|
||||
- [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl <path>` 前缀
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm run sync:skill-assets # reference/ + SKILL metadata.version 与 catalog / package.json 一致
|
||||
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
|
||||
pnpm run sync:skill-assets
|
||||
pnpm -F bailian-cli exec tsx src/main.ts <new-command> --help
|
||||
pnpm -F bailian-cli exec tsx src/main.ts
|
||||
vp test packages/commands/tests/e2e/<topic>.e2e.test.ts
|
||||
```
|
||||
|
||||
如改了 `kscli` 入口:
|
||||
|
||||
```sh
|
||||
pnpm -F knowledge-studio-cli exec tsx src/main.ts <command> --help
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 只改了命令文件,忘了 **`catalog.ts`** → 命令不存在或 help 里没有
|
||||
- ✗ 手改 **`skills/bailian-cli/reference/*.md`** → 下次 generate 被覆盖;应改 `defineCommand` 后重新 generate 并提交
|
||||
- ✗ 在 `export-schema.ts` 顶层 `import catalog` → 可能与 registry 循环依赖
|
||||
- ✗ 只新增 `packages/commands/src/commands/...` 文件,忘了在 `packages/commands/src/index.ts` 导出
|
||||
- ✗ 只导出了命令实现,忘了在 `packages/cli/src/commands.ts` 暴露路径 → `bl --help` 看不到
|
||||
- ✗ 手改 `skills/bailian-cli/reference/*.md` → 下次 generate 被覆盖;应改 command metadata 后重新 generate 并提交
|
||||
- ✗ 在 `usageArgs` / `exampleArgs` 写死 `bl text chat` → `kscli` 等入口复用时 help 错
|
||||
- ✗ Console Gateway 命令忘设 `auth: "console"` → console flags / credential 注入都不生效
|
||||
- ✗ 单 action 的子组是反模式,新增时优先拍平为两级
|
||||
|
||||
@@ -11,20 +11,21 @@
|
||||
|
||||
### 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
|
||||
- [ ] `packages/commands/src/commands/<group>/<action>.ts`:
|
||||
- `defineCommand({ flags: { ... } })` 里增删/改 camelCase flag key 与 `{ type, valueHint, description, required }`
|
||||
- `usageArgs` 字段只写参数片段(如 `"--message <text> [flags]"`),不写 `bl <path>`
|
||||
- `exampleArgs` 数组覆盖新 flag 至少一个示例,同样不写 bin/path 前缀
|
||||
- `run()` 里只从 `ctx.flags` 读取本命令 flag,从 `ctx.settings` 读取全局/config 解析结果
|
||||
- 类型由 `ParsedFlags<typeof FLAGS>` 推导;避免手写 `flags.x as number` 这类断言
|
||||
- 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate`
|
||||
- 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config
|
||||
|
||||
### 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`
|
||||
- [ ] 如果是**全局 flag**(所有命令通用),改 `packages/core/src/types/command.ts` 的 `GLOBAL_FLAGS`
|
||||
- [ ] 如果是凭证域 flag,优先确认是否属于 `MODEL_AUTH_FLAGS` 或 `CONSOLE_AUTH_FLAGS`;不要在单个命令里重复声明
|
||||
- [ ] 如果新 flag 影响有效配置面,改 `packages/core/src/config/schema.ts` 的 `Settings` 接口
|
||||
- [ ] 如果对应 env var 或 config 文件字段,改 `packages/core/src/config/loader.ts` 的 `buildSettings`
|
||||
|
||||
### C. 文档层
|
||||
|
||||
@@ -44,13 +45,13 @@
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
node packages/cli/src/main.ts <command> --help # 看新 flag 出现在 Options
|
||||
node packages/cli/src/main.ts <command> --new-flag x # 实测一遍
|
||||
pnpm -F bailian-cli exec tsx src/main.ts <command> --help # 看新 flag 出现在 Flags
|
||||
pnpm -F bailian-cli exec tsx 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 的命令文件作为示例)
|
||||
- ✗ 在 `usageArgs` / `exampleArgs` 里写死 `bl <path>`,导致其它产品入口复用时 help 错
|
||||
- ✗ required flag 缺失又在 `run()` 里重复手写校验,与 parser/`validate` 的错误文案不一致
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Command Pack 维护
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 新增或移除 Command Pack 包
|
||||
- 调整包白名单、允许的命令前缀或协议字段
|
||||
- 修改 `plugin install/link/list/remove`
|
||||
- 修改 Command Pack 加载、隔离、兼容性或独立安装目录
|
||||
|
||||
## 分层边界
|
||||
|
||||
- `packages/core/src/types/command-pack.ts`:稳定的协议元数据和导出类型,不知道具体产品或白名单。
|
||||
- `packages/runtime/src/command-packs/`:所有 CLI 共用的加载、校验、API 适配、产品隔离安装目录和 manager 实现。
|
||||
- `packages/runtime/src/create-cli.ts`:始终接收静态 command map,按 `CliOptions.commandPacks` 统一合并 pack,并把已绑定产品 identity/policy 的 manager 注入 `ctx.commandPacks`。
|
||||
- `packages/commands/src/commands/plugin/`:普通共享管理命令,只依赖 `ctx.commandPacks`,不 import 任何产品 policy。
|
||||
- `packages/cli/src/command-pack-policy.ts`:`bl` 支持的包、命令前缀和凭据授权。
|
||||
- `kscli` 当前不传 `commandPacks`,使用 runtime 的默认空 policy。
|
||||
- 当前只有 `bl` 从 `bailian-cli-commands` 导入并登记 `plugin *`;使用默认空 policy 的产品不提前暴露管理命令。
|
||||
|
||||
不要把产品白名单写进 core/runtime,也不要通过扫描全局 `node_modules` 自动发现包。通用机制放 runtime,产品差异只由 policy 表达。
|
||||
|
||||
## 安全与兼容性清单
|
||||
|
||||
- [ ] 包名必须精确命中当前产品 policy 的 `supported`,命令路径必须位于该包允许的前缀。
|
||||
- [ ] 正式安装只接受包名加 version/tag;本地目录只走 `plugin link`。
|
||||
- [ ] npm 使用独立安装目录和 `--ignore-scripts`,不污染 CLI 自身依赖树。
|
||||
- [ ] npm 子进程只继承明确允许的 registry/config/cache/proxy/TLS 配置,不通配透传 pnpm 注入的 `npm_config_*`。
|
||||
- [ ] 安装目录按 `identity.npmPackage` 隔离,不能让一个产品安装/删除另一个产品的 pack。
|
||||
- [ ] 安装目录只隔离依赖位置,不隔离执行权限;Command Pack 必须视为 CLI 进程内的完全可信代码。
|
||||
- [ ] 入口 realpath 不能逃逸包根目录。
|
||||
- [ ] 加载前检查 `type`、`apiVersion`、`minCliVersion`;报告状态只使用 `loaded/failed`,具体原因写入 `error`。
|
||||
- [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。
|
||||
- [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。
|
||||
- [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`。
|
||||
- [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore` 或 `configStore`。
|
||||
- [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager,避免 pack 安装或删除其他 pack。
|
||||
- [ ] 单包失败必须 fail-open:保留内置命令和其他合法 pack。
|
||||
- [ ] 破坏协议前优先在适配层兼容;确实无法兼容时才提升 `apiVersion`。
|
||||
|
||||
## 测试与文档
|
||||
|
||||
- [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。
|
||||
- [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。
|
||||
- [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。
|
||||
- [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
|
||||
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`;公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
|
||||
|
||||
验证:
|
||||
|
||||
```sh
|
||||
vp test packages/runtime/tests/command-packs.test.ts
|
||||
vp test packages/cli/tests/e2e/command-packs.e2e.test.ts
|
||||
vp test packages/kscli/tests/e2e/command-packs.e2e.test.ts
|
||||
pnpm run sync:skill-assets
|
||||
vp check
|
||||
```
|
||||
+19
-19
@@ -11,46 +11,46 @@
|
||||
|
||||
```
|
||||
flag (--xxx) ─┐
|
||||
├─ loadConfig() 合并 ─→ Config(运行时单一对象)
|
||||
├─ buildSources() + buildSettings() ─→ Settings(命令读取面)
|
||||
env (XXX=yyy) ─┤
|
||||
│
|
||||
config 文件 ─┘
|
||||
~/.bailian/config.json
|
||||
```
|
||||
|
||||
优先级一般是 **flag > env > config 文件 > 默认值**,具体见 `core/config/loader.ts`。
|
||||
优先级一般是 **flag > env > config 文件 > 默认值**,具体见 `packages/core/src/config/loader.ts`。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. 类型定义
|
||||
|
||||
- [ ] `packages/core/src/config/schema.ts`:
|
||||
- `Config`(运行时形状)加新字段
|
||||
- `Settings`(运行时有效配置面)加新字段
|
||||
- `ConfigFile`(disk 形状,snake_case)加新字段(如果允许写文件)
|
||||
- `parseConfigFile()` 解析新字段
|
||||
- 如果是 enum 字段,加校验
|
||||
|
||||
### B. 加载逻辑
|
||||
|
||||
- [ ] `packages/core/src/config/loader.ts:loadConfig()`:
|
||||
- 加新字段的合并逻辑(`flags.x ?? process.env.XXX ?? file.x ?? default`)
|
||||
- [ ] `packages/core/src/config/loader.ts`:
|
||||
- `buildSources()` 如需新增来源,把 flag/file/env 纳入 sources
|
||||
- `buildSettings()` 加新字段的合并逻辑(`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 正确解析
|
||||
- [ ] `packages/core/src/types/command.ts:GLOBAL_FLAGS`
|
||||
- [ ] `packages/runtime/src/registry.ts` 会**自动**从 `GLOBAL_FLAGS` 生成 root help;`tools/generate-reference.ts` 会生成 `reference/index.md` 的全局 flag 段
|
||||
- [ ] flag 的 type 标注(`switch` / `boolean` / `number` / `array` / `string`),让 `packages/runtime/src/args.ts` 正确解析
|
||||
- [ ] 改完全局 flag 后跑 `pnpm --filter bailian-cli run generate:reference`
|
||||
|
||||
### D. 命令使用方
|
||||
|
||||
- [ ] 用到新字段的命令文件直接读 `config.xxx`,不要重复解析
|
||||
- [ ] 用到新字段的命令文件直接读 `ctx.settings.xxx`,不要重复解析 env/config
|
||||
- [ ] 配置展示 / 修改命令同步:
|
||||
- `packages/cli/src/commands/config/show.ts` 显示新字段
|
||||
- `packages/cli/src/commands/config/set.ts` 允许 set
|
||||
- `packages/cli/src/commands/config/export-schema.ts` 在 schema 输出里
|
||||
- `packages/commands/src/commands/config/show.ts` 显示新字段
|
||||
- `packages/commands/src/commands/config/set.ts` 的 `VALID_KEYS` / `KEY_ALIASES` / description 允许 set
|
||||
|
||||
### E. 文档
|
||||
|
||||
@@ -66,19 +66,19 @@ config 文件 ─┘
|
||||
|
||||
```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>
|
||||
pnpm -F bailian-cli exec tsx src/main.ts config show --output json | grep <new-field>
|
||||
XXX=value pnpm -F bailian-cli exec tsx src/main.ts config show --output json | grep <new-field>
|
||||
pnpm -F bailian-cli exec tsx src/main.ts config show --xxx value --output json | grep <new-field>
|
||||
|
||||
# 写到文件
|
||||
node packages/cli/src/main.ts config set --key <key> --value <value>
|
||||
# 写到文件(会改用户 HOME,必要时先用临时 HOME)
|
||||
pnpm -F bailian-cli exec tsx src/main.ts config set --key <key> --value <value>
|
||||
cat ~/.bailian/config.json
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ `Config` 接口加字段但 `loadConfig` 没填,运行时永远 undefined
|
||||
- ✗ `Settings` 接口加字段但 `buildSettings` 没填,运行时永远 undefined
|
||||
- ✗ `ConfigFile` 用 camelCase 字段名(disk schema 应该是 snake_case)
|
||||
- ✗ 全局 flag 没标 `type: "boolean"`,被当成需要值的 `--xxx <value>`
|
||||
- ✗ 全局 switch 没标 `type: "switch"`,被当成需要值的 `--xxx <value>`
|
||||
- ✗ 加了 env var 但 README 表格没更新,用户不知道有这条
|
||||
- ✗ `config show` 不显示新字段,用户改了无法回查
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Config Profile 与激活状态变更清单
|
||||
|
||||
适用于新增 Profile 预设、修改命名 Profile 选择规则、调整 `active_config`,或新增/修改 `bl config list/use/show/ui` 等 Profile 管理能力。
|
||||
|
||||
## 1. 保持存储边界
|
||||
|
||||
- Profile 业务字段继续由 `ConfigFile` / `CONFIG_FILE_KEYS` 管理。
|
||||
- `active_config` 是 `config.json` 顶层元数据,不得进入命名 Profile block,也不得被 `config set` 当作普通字段写入。
|
||||
- 识别命名 Profile 时必须排除业务字段和顶层元数据。
|
||||
- 旧配置缺少 `active_config` 时继续等价于激活 `default`。
|
||||
|
||||
## 2. 保持选择语义
|
||||
|
||||
```text
|
||||
显式 --config <name> > active_config > default
|
||||
```
|
||||
|
||||
- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`。
|
||||
- `--config default` 必须显式选择顶层配置并绕过命名激活项。
|
||||
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
|
||||
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
|
||||
- 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。
|
||||
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
|
||||
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。
|
||||
|
||||
## 3. 保持读写命令交互一致
|
||||
|
||||
- `auth login`、`config set` 等写命令未传 `--config` 时修改当前激活项。
|
||||
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时
|
||||
创建和激活;`config set --config <name>` 可创建但不自动激活。
|
||||
- `config show`、`auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。
|
||||
- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。
|
||||
- 按凭证域退出时必须清理该域的完整字段集合,例如 OpenAPI 同时清理 AK、SK 和 STS `security_token`。
|
||||
- 所有生产代码读取“当前配置”时优先经过 `buildSources()` 或携带解析后的 `configName`;直接调用无名称的 `readConfigFile()` / `writeConfigFile()` 只适用于明确操作顶层 default 的底层能力。
|
||||
|
||||
## 4. 保持状态一致性
|
||||
|
||||
- `config use` 只能激活已经存在的命名 Profile;`default` 始终有效。
|
||||
- 配置文件中的 `active_config` 指向不存在的 Profile 时返回 usage error,不静默回退。
|
||||
- 删除当前激活的命名 Profile 时,同一次落盘切回 `default`,不得留下悬空引用。
|
||||
- 配置写入继续使用临时文件 + rename,避免中断后留下半写文件。
|
||||
|
||||
## 5. 命令与展示联动
|
||||
|
||||
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。
|
||||
- `config list` 标识所有 Profile 与当前激活项。
|
||||
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
|
||||
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
|
||||
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
|
||||
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
|
||||
|
||||
## 6. 最小测试矩阵
|
||||
|
||||
- 旧配置无 `active_config` -> `default`。
|
||||
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
|
||||
- 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。
|
||||
- 激活不存在的 Profile 失败且不写盘。
|
||||
- 悬空 `active_config` 明确失败。
|
||||
- 删除激活 Profile 后切回 `default`。
|
||||
- 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。
|
||||
- 显式 `auth login --config <name>` 成功后激活该 Profile,失败或 dry-run 不创建、不切换;
|
||||
`--config default` 成功后切回 `default`。
|
||||
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
|
||||
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
|
||||
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
|
||||
|
||||
## 7. 完成检查
|
||||
|
||||
```sh
|
||||
pnpm run sync:skill-assets
|
||||
vp check
|
||||
vp test
|
||||
```
|
||||
|
||||
命令 E2E 会启动本地子进程,Config UI 测试还会监听 `127.0.0.1` 临时端口;受限沙箱内出现 `EPERM` 时,需要在允许本地进程和端口的环境中复跑。
|
||||
@@ -3,8 +3,8 @@
|
||||
## 触发条件
|
||||
|
||||
- 修改 `BailianError` 的 message 或 hint
|
||||
- 调整 cli 的 hint 增强逻辑(`enhanceHint`)
|
||||
- 改 ensure-key 的 setup 流程文案
|
||||
- 调整 runtime 的 hint 增强逻辑(`enhanceHint`)
|
||||
- 改 auth stage / resolver 的鉴权失败文案
|
||||
- 改任何抛错位置的分类(exitCode)
|
||||
|
||||
> 注意:`mapApiError` **不再做错误分类**(参见下方"边界原则")。如果你想给某种 HTTP 错误码加白名单分类,请先回到本文档读完"边界原则"再说。
|
||||
@@ -17,7 +17,7 @@
|
||||
| ---------------------------------------------------- | -------- | ----------------------------------------------------------- |
|
||||
| 命令解析、缺 flag、参数校验 | **内部** | `BailianError(USAGE)` |
|
||||
| 文件 I/O(ENOENT/EACCES/...) | **内部** | `BailianError(GENERAL)` + errno-specific hint |
|
||||
| 本地 credentials 缺失(resolver/ensure-key/AK-SK 等) | **内部** | `BailianError(AUTH)` |
|
||||
| 本地 credentials 缺失(resolver / authStage 等) | **内部** | `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 原样透传**,不分类、不替换 |
|
||||
@@ -39,9 +39,9 @@
|
||||
```
|
||||
core 抛出 BailianError(message, exitCode, hint, cause?)
|
||||
↓ 沿调用栈冒泡
|
||||
cli/main.ts: main().catch(handleError)
|
||||
runtime/create-cli.ts: dispatch().catch(handleError)
|
||||
↓
|
||||
cli/error-handler.ts:
|
||||
runtime/error-handler.ts:
|
||||
- 服务端错误(BailianError(GENERAL)) → text 直接打 message
|
||||
- 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强)
|
||||
- TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK
|
||||
@@ -62,36 +62,42 @@ process.exit(err.exitCode)
|
||||
|
||||
- ❌ 不要回退到"401 → AUTH、429 → QUOTA"那套白名单
|
||||
- ✅ message 把 status / apiCode / request_id 拼进去就够,exit 统一 GENERAL
|
||||
- 例外:CLI **自己**因为本地状态产生的 BailianError(resolver、ensure-key 等)可以用语义化 exitCode
|
||||
- 例外:CLI/runtime **自己**因为本地状态产生的 BailianError(resolver、authStage 等)可以用语义化 exitCode
|
||||
|
||||
### 3. core 的 hint 必须不含 cli 关切
|
||||
|
||||
- ❌ 不写 `bl xxx` 命令名
|
||||
- ❌ 不写控制台 URL 或 region
|
||||
- ❌ 不写渠道追踪参数(`source_channel=xxx`)
|
||||
- ❌ 新增/改动时不写 `bl xxx` 命令名
|
||||
- ❌ 新增/改动时不写 `kscli xxx` 等产品入口命令名
|
||||
- ❌ 新增/改动时不写控制台 URL 或 region
|
||||
- ❌ 新增/改动时不写渠道追踪参数(`source_channel=xxx`)
|
||||
- ✅ 只描述抽象做法(如 `"Set DASHSCOPE_API_KEY environment variable, or pass --api-key."`)
|
||||
- 当前遗留:`packages/core/src/auth/resolver.ts` 仍含 `bl auth login` hint;触碰鉴权错误时迁到 runtime `enhanceHint`
|
||||
|
||||
### 4. cli 端可以自由使用 cli 命令名 + URL
|
||||
### 4. runtime / 产品层可以使用入口名 + URL
|
||||
|
||||
- 命令文件、`error-handler.ts`、`utils/ensure-key.ts` 是 cli 层,内部可以写 `bl xxx`
|
||||
- URL 必须从 `packages/cli/src/urls.ts` import,不能硬编码
|
||||
- `packages/runtime/src/error-handler.ts` 通过 `binName` 渲染 `bl` / `kscli` 等入口名,不要硬编码
|
||||
- 产品入口 / README / E2E 可以写具体入口命令
|
||||
- shared command 实现不写 `bl` / `kscli` 前缀;`usageArgs` / `exampleArgs` 只写参数片段
|
||||
- URL 必须从 `packages/runtime/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:同上
|
||||
- [ ] `packages/core/src/auth/resolver.ts` 新增/改 throw 语句时:hint 不含 cli 关切;已有 `bl auth login` 遗留点被触碰时要收敛
|
||||
- [ ] 任何 core 文件新增/改 BailianError:同上
|
||||
|
||||
### B. cli 增强(`enhanceHint`)
|
||||
### B. runtime 增强(`enhanceHint`)
|
||||
|
||||
- [ ] `packages/cli/src/error-handler.ts:enhanceHint`:**当前只为 internal AUTH 增强**(因为只有 resolver/ensure-key 等内部位置会发 AUTH)
|
||||
- [ ] `packages/runtime/src/error-handler.ts:enhanceHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH)
|
||||
- [ ] 命令名使用 `binName`,不要硬编码 `bl`
|
||||
- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"`
|
||||
|
||||
### C. cli 直接抛错(`ensure-key`、命令文件)
|
||||
### C. command / runtime 直接抛错
|
||||
|
||||
- [ ] cli 层抛 BailianError 时,hint 里可以放 cli 命令名,但 **URL 一律走 `urls.ts` import**
|
||||
- [ ] runtime 层抛 BailianError 时,hint 里可以放 `binName` 渲染的入口命令,但 **URL 一律走 `urls.ts` import**
|
||||
- [ ] `packages/commands` 作为 shared command 库,默认不硬编码产品 bin;如果确需用户操作提示,优先依赖 runtime error handler 或 `ctx.identity.binName`
|
||||
- [ ] 抛错位置如果**已经在调用服务端**,catch 时不要替换 message——重新评估是否需要 catch
|
||||
|
||||
### D. 文案一致性
|
||||
@@ -104,14 +110,14 @@ process.exit(err.exitCode)
|
||||
|
||||
```sh
|
||||
# 触发对应错误,看 text 输出
|
||||
HOME=/tmp/empty node packages/cli/src/main.ts text chat --message "x" --non-interactive
|
||||
HOME=/tmp/empty pnpm -F bailian-cli exec tsx src/main.ts text chat --message "x"
|
||||
|
||||
# 看 JSON 输出(应包含 cause 字段当 cause 存在时)
|
||||
HOME=/tmp/empty node packages/cli/src/main.ts text chat --message "x" --non-interactive --output json
|
||||
HOME=/tmp/empty pnpm -F bailian-cli exec tsx src/main.ts text chat --message "x" --output json
|
||||
|
||||
# 模拟网络层错误,验证 errno 透传
|
||||
DASHSCOPE_BASE_URL=https://nonexistent-host.invalid \
|
||||
node packages/cli/src/main.ts text chat --message hi
|
||||
pnpm -F bailian-cli exec tsx src/main.ts text chat --message hi
|
||||
# 预期:"Network request failed: ENOTFOUND ..." + Caused by 链
|
||||
```
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
### A. 版本一致性
|
||||
|
||||
- [ ] `package.json` 的 `engines.node` 与 README 的 Node.js 徽章一致
|
||||
- [ ] 发布包(`cli` 等)的 `engines.node` 与 README 的 Node.js 徽章一致;根/e2e 开发要求(`>=22.12`)与 CONTRIBUTING 一致
|
||||
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
|
||||
- [ ] 三处 `tsconfig.json`(根 + cli + core)的 target / module 设置一致
|
||||
- [ ] 各源码包 `tsconfig.json`(根 + core + runtime + commands + cli + kscli)的 target / module 设置一致
|
||||
|
||||
### B. lint / format 规则改动
|
||||
|
||||
@@ -26,13 +26,15 @@
|
||||
|
||||
### 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` 会断言)
|
||||
- [ ] `packages/*/vite.config.ts` 的 entry / dts / exports 设置符合包类型:
|
||||
- library 包(core/runtime/commands):本地 `exports` 默认指向 `src/index.ts`;`publishConfig.exports` 覆盖发布入口为 `dist/index.mjs`;dts 产物正常生成
|
||||
- binary 包(cli/kscli):entry 指向 `src/main.ts`,有 shebang,`exports: true`
|
||||
- [ ] cli / kscli 的 bundle 必须把 workspace 包(`bailian-cli-core` / `bailian-cli-runtime` / `bailian-cli-commands`)当 **external**(不内联),确认 dist 中仍是 package import
|
||||
- [ ] cli / kscli 的 binary bundle 第一行必须有 `#!/usr/bin/env node` shebang
|
||||
|
||||
### D. 依赖升级
|
||||
|
||||
- [ ] 检查 `bailian-cli-core` 在 cli 的 `dependencies` 里仍是 `"workspace:*"`(不要变成实际版本号 — `tools/release.mjs` 会拦)
|
||||
- [ ] 检查 workspace 内部依赖在 `dependencies` 里仍是 `"workspace:*"`(不要手改成实际版本号;发布时由 pack/publish 流程解析)
|
||||
- [ ] 升级后跑 `vp check && vp test`
|
||||
- [ ] 升级 `@types/node` 时注意 Node API 变化(如 fs.existsSync 行为)
|
||||
|
||||
@@ -40,11 +42,11 @@
|
||||
|
||||
- [ ] `.vite-hooks/pre-commit` 改动后,`pnpm install` 重新软链(走 `prepare: vp config`)
|
||||
- [ ] 增加 hook 时,确认在干净 clone 后能自动激活
|
||||
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(先 build core,再 `generate:reference` + `sync:skill-version`)并 `git add` skill 资产,最后 `vp staged`
|
||||
- [ ] pre-commit 会跑 `pnpm run sync:skill-assets`(`generate:reference` 含格式化 + `sync:skill-version`,直接读源码、无需先 build)并 `git add` skill 资产,最后 `vp staged`
|
||||
|
||||
### F. CI / 发版工具
|
||||
|
||||
- [ ] `tools/release.mjs` 中如有版本/规则相关的硬编码,同步更新
|
||||
- [ ] `tools/release/` 中如有版本/规则相关的硬编码,同步更新
|
||||
- [ ] 比如 `secretPatterns` 添加新的敏感值识别
|
||||
|
||||
## 完成后自查
|
||||
@@ -54,13 +56,13 @@
|
||||
pnpm install --frozen-lockfile
|
||||
vp check
|
||||
vp test
|
||||
node tools/release.mjs check
|
||||
node tools/release/check.mjs
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 升级 Node engines 但忘了 README 徽章
|
||||
- ✗ 改 lint 规则后没全仓 `--fix`,新人 PR 报红一片
|
||||
- ✗ 改 cli 的 vite config 把 core 不小心打成 inline,bundle 体积暴涨
|
||||
- ✗ 改 cli/kscli 的 vite config 把 core/runtime/commands 不小心打成 inline,bundle 体积暴涨
|
||||
- ✗ Oxlint 配置改了但 IDE 缓存还是旧的(IDE 可能要重启 ts server)
|
||||
- ✗ 升级依赖一并升 lockfile,改动量大但没拆 commit
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
### Do
|
||||
|
||||
- 写清晰的 **must / must-not / 必查**,不写"建议"性语气
|
||||
- 用 file path + 具体 action 的句式(`packages/cli/src/commands/catalog.ts:增加 import 与 commands 条目`)
|
||||
- 用 file path + 具体 action 的句式(`packages/cli/src/commands.ts:增加产品命令 map 条目`)
|
||||
- 在每份场景末尾留**常见漏点**段,持续累积真实经验
|
||||
- 在跨场景的不变量上互相引用,不复制
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
|
||||
### A. 命令实现
|
||||
|
||||
- [ ] `packages/cli/src/commands/<group>/<action>.ts`:
|
||||
- [ ] `packages/commands/src/commands/<group>/<action>.ts`:
|
||||
- `--model` flag 的 description 里"default:"反映新默认值
|
||||
- 命令内部 `const model = (flags.model as string) || "<default>"` 的 fallback 字符串
|
||||
- 命令内部 `const model = flags.model || settings.defaultXxxModel || "<default>"` 的 fallback 字符串
|
||||
- 如果命令维护一个 supported-models 列表(如 `speech/synthesize.ts:MODEL_VOICES`),增删条目
|
||||
- 如果不同模型有不同 endpoint / 请求体形状,确保 `if (model.startsWith("xxx"))` 分支覆盖
|
||||
- [ ] 模型如有特殊 endpoint,看 `packages/core/src/client/endpoints.ts`
|
||||
- [ ] 如果新增的是某产品入口专属能力,确认 `packages/cli/src/commands.ts` 或其它入口 map 是否需要暴露/隐藏
|
||||
|
||||
### B. 类型层
|
||||
|
||||
@@ -41,9 +42,9 @@
|
||||
|
||||
```sh
|
||||
# 默认模型走通
|
||||
node packages/cli/src/main.ts <command> --message "test"
|
||||
pnpm -F bailian-cli exec tsx src/main.ts <command> --message "test"
|
||||
# 显式指定新模型
|
||||
node packages/cli/src/main.ts <command> --model <new-model> --message "test"
|
||||
pnpm -F bailian-cli exec tsx src/main.ts <command> --model <new-model> --message "test"
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
+45
-29
@@ -20,37 +20,40 @@
|
||||
|
||||
### channel 发布
|
||||
|
||||
1. 在 GitHub 触发 Publish workflow,mode 选 `channel`,channel 填 dist-tag 名(如 `mcp`)
|
||||
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` 版本号 → 自检 → 构建 → 发布到指定 dist-tag
|
||||
1. 在 GitHub 触发 Publish workflow,package 选 `bailian-cli` 或 `knowledge-studio-cli`,mode 选 `channel`,channel 填 dist-tag 名(如 `mcp`)
|
||||
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` 版本号 → 临时 bump 对应包集合 → 自检 → 构建 → 发布到指定 dist-tag
|
||||
3. 对应脚本:`tools/release/publish-channel.mjs`
|
||||
|
||||
### stable 发布
|
||||
|
||||
1. 确保 `packages/cli/package.json` 和 `packages/core/package.json` 已升到目标版本且一致
|
||||
2. 在 GitHub 触发 Publish workflow,mode 选 `stable`
|
||||
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli`,`knowledge-studio-cli` 发布会额外包含 `packages/kscli`
|
||||
2. 在 GitHub 触发 Publish workflow,package 选目标包集合,mode 选 `stable`
|
||||
3. 需要 production environment 审批人批准
|
||||
4. CI 自动:自检 → 构建 → 发布到 latest → 打 git tag
|
||||
5. 对应脚本:`tools/release/publish-stable.mjs`
|
||||
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
|
||||
5. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,CI 会继续补发缺失包
|
||||
6. 对应脚本:`tools/release/publish-stable.mjs`
|
||||
|
||||
## 自检(`tools/release/check.mjs`)
|
||||
|
||||
两种模式都会先跑 `check.mjs`,覆盖以下检查:
|
||||
|
||||
| 检查项 | 说明 |
|
||||
| -------------------------------- | ----------------------------------------- |
|
||||
| `pnpm install --frozen-lockfile` | lockfile 一致性 |
|
||||
| README 同步 | `packages/cli/README.md` 与根 README 一致 |
|
||||
| 版本号一致 | cli 与 core 的 version 字段相同 |
|
||||
| `workspace:*` 替换 | cli 对 core 的依赖解析为真实版本号 |
|
||||
| 构建 core + cli | `pnpm build` |
|
||||
| pnpm pack | 打 tarball |
|
||||
| publint | 包元数据校验 |
|
||||
| gitleaks | 敏感信息扫描 |
|
||||
| 检查项 | 说明 |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `pnpm install --frozen-lockfile` | lockfile 一致性 |
|
||||
| README 同步 | `packages/cli/README.md` 与根 README 一致 |
|
||||
| 版本号一致 | `tools/release/lib/packages.mjs` 中待发布包集合 version 相同 |
|
||||
| `workspace:*` 替换 | 发布包间 workspace 依赖解析为真实版本号 |
|
||||
| 构建 | 基础发布构建 core/runtime/commands 依赖和 cli;`--knowledge` 额外构建 `knowledge-studio-cli` |
|
||||
| 生成资产 | 重建 `skills/bailian-cli/reference/`;非 channel 模式还同步 `skills/bailian-cli/SKILL.md` version |
|
||||
| pnpm pack | 打 tarball |
|
||||
| publint | 包元数据校验 |
|
||||
| gitleaks | 敏感信息扫描 |
|
||||
|
||||
本地可以 dry-run 验证:
|
||||
|
||||
```sh
|
||||
node tools/release/publish-channel.mjs --channel test --dry-run
|
||||
node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
```
|
||||
|
||||
## CI 基础设施
|
||||
@@ -58,34 +61,47 @@ node tools/release/publish-channel.mjs --channel test --dry-run
|
||||
- **认证**:npm OIDC Trusted Publishing(无 token),需要 `id-token: write` 权限
|
||||
- **Node 版本**:24(npm 11.5+ 才支持 OIDC token 交换)
|
||||
- **Actions 版本**:checkout/setup-node/pnpm-action 均为 v6(Node 24 兼容)
|
||||
- **npm 配置**:两个包的 Trusted Publisher 都指向 `modelstudioai/cli` 的 `publish.yml`,environment 留空
|
||||
- **npm 配置**:当前 release tooling 发布的包(`bailian-cli-core` / `bailian-cli-runtime` / `bailian-cli-commands` / `bailian-cli` / `knowledge-studio-cli`)的 Trusted Publisher 指向 `modelstudioai/cli` 的 `publish.yml`;新增发布包时同步 npm Trusted Publisher
|
||||
|
||||
## `check.mjs` 不覆盖的(手动确认)
|
||||
|
||||
### 版本号目标(仅 stable)
|
||||
|
||||
- [ ] `packages/cli/package.json` 和 `packages/core/package.json` 已升到目标版本
|
||||
- [ ] `tools/release/lib/packages.mjs` 覆盖的目标包集合已升到目标版本且一致
|
||||
- [ ] 源码包 `packages/core/package.json`、`packages/runtime/package.json`、`packages/commands/package.json`、`packages/cli/package.json`、`packages/kscli/package.json` 是否需要同步升版已人工确认;当前仓库通常保持五包版本一致
|
||||
- [ ] `tools/release/lib/packages.mjs` 的 `PACKAGES` 覆盖基础发布包;`KSCLI_PACKAGE` / `ALL_PACKAGES` 覆盖 `knowledge-studio-cli` 发布路径;如果新增发布包,同步 `publish-stable.mjs` / `publish-channel.mjs` 的 bump、publish、idempotency 逻辑和 `.github/workflows/publish.yml` 的 package 选项
|
||||
- [ ] pre-release 格式正确(`1.0.0-beta.0` / `1.0.0-rc.1`,**不要直接用 `1.0.0` 当 beta**)
|
||||
|
||||
### CHANGELOG(仅 stable)
|
||||
|
||||
- [ ] `CHANGELOG.md` 和 `CHANGELOG.zh.md` 都已新增目标版本条目,中英文一一对应
|
||||
- [ ] 分类标题用 Keep a Changelog 规范的 `Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`(中文版对应 `新增` / `变更` / `已弃用` / `已移除` / `修复` / `安全`),**不要自创 `Improved` / `优化` 等规范外分类**
|
||||
- [ ] 条目日期与发版日期一致
|
||||
|
||||
### 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README.zh.md` 的 Quick Start 命令仍能跑通
|
||||
- [ ] README 的 Node.js 徽章版本与 `cli/package.json.engines.node` 一致
|
||||
- [ ] README 宣传的 bin 名称在 `cli/package.json.bin` 都真的注册
|
||||
- [ ] `LICENSE` 文件存在(根 + cli + core 各一份)
|
||||
- [ ] `packages/kscli/README.md` / `README.zh.md` 与 `knowledge-studio-cli` 的 bin、控制台 URL、认证方式一致
|
||||
- [ ] `LICENSE` 文件存在(根 + 当前实际发布包;新增发布包时补该包 LICENSE)
|
||||
|
||||
## 完成后
|
||||
|
||||
- [ ] 验证 npm 上能装:`npm view bailian-cli@<tag> version`
|
||||
- [ ] 试装一次:`npm i -g bailian-cli@<tag> && bl --version`
|
||||
- [ ] 验证 npm 上能装:`npm view bailian-cli@<tag> version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@<tag> version`
|
||||
- [ ] 试装一次:`npm i -g bailian-cli@<tag> && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@<tag> && kscli --version`
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| ----------------------------------------------------- | -------------------------------------------------- |
|
||||
| cli 升版号但 core 没升 | check.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 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章与 `cli/package.json.engines` 不一致(当前应为 `>=18.17`) | 用户在声明外的 Node 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
|
||||
@@ -115,19 +115,18 @@ pnpm run test:stress -- video-edit --reuse-fixtures -- --count 3
|
||||
|
||||
### 子进程调用方式
|
||||
|
||||
- **实际执行**:`node packages/cli/src/main.ts <args>`,`cwd` 为 `packages/cli`
|
||||
- **实际执行**:仓库本地 `tsx 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`;须阻塞到任务完成(及下载路径正确时落盘)。
|
||||
**禁止**对视频相关子进程加 `--async`;须阻塞到任务完成(及下载路径正确时落盘)。
|
||||
|
||||
### 成功 / 失败判定(概要)
|
||||
|
||||
@@ -192,8 +191,8 @@ pnpm run test:stress -- video-edit --reuse-fixtures -- --count 3
|
||||
### 只改压测脚本时
|
||||
|
||||
- [ ] `lib/paths.mjs` 解析的 `CLI_PACKAGE` / `MONOREPO_ROOT` 仍正确
|
||||
- [ ] 子进程仍为 `node` + `src/main.ts`,未改回裸 `pnpm run dev` 执行任务
|
||||
- [ ] 未对子进程加 `--quiet`,视频未加 `--no-wait`
|
||||
- [ ] 子进程仍为仓库本地 `tsx` + `src/main.ts`,未改回裸 `pnpm run dev` 执行任务
|
||||
- [ ] 未对子进程加 `--quiet`,视频未加 `--async`
|
||||
- [ ] `parsers.mjs` 与文档中的成功判定一致
|
||||
- [ ] 根 `package.json` 仅保留 `test:stress` 入口指向 `run.mjs`
|
||||
- [ ] `node --check` 对相关 `.mjs` 通过,`pnpm run test:stress -- list` 可运行
|
||||
|
||||
+15
-12
@@ -13,12 +13,13 @@
|
||||
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)
|
||||
BAILIAN_HOST bailian.cn-beijing.aliyuncs.com (OpenAPI)
|
||||
|
||||
cli/src/urls.ts ← 用户面控制台 URL(cn-only)
|
||||
runtime/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
|
||||
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
@@ -29,14 +30,16 @@ core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
### A. TS 源码(必须 import,不准硬编码)
|
||||
|
||||
- [ ] `packages/core/src/config/schema.ts` 是所有 API/docs 基址的源头
|
||||
- [ ] `packages/cli/src/urls.ts` 是所有用户面控制台 URL 的源头
|
||||
- [ ] `packages/runtime/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
|
||||
# 期望:匹配 packages/runtime/src/urls.ts;
|
||||
# 当前遗留例外:packages/commands/src/commands/auth/login-console.ts(登录站点映射)、
|
||||
# packages/core/src/advisor/recommend.ts(模型文档 deep link)。触碰时优先收敛到统一 URL 模块。
|
||||
|
||||
# API endpoint — 应只在 schema.ts 和 upload.ts 出现
|
||||
grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
|
||||
@@ -51,24 +54,24 @@ grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
|
||||
|
||||
### C. 渠道追踪参数
|
||||
|
||||
- [ ] **当前现状**:全仓不带 `source_channel=aliway` 等追踪参数
|
||||
- [ ] 如未来要恢复以收集分析数据,**统一评估再加回**(不要单点恢复造成不一致)
|
||||
- [ ] 全仓 grep `source_channel=`,确认无残留
|
||||
- [ ] **当前现状**:TS 源码不带 `source_channel=...`;README / package README 中保留 `cli_github` / `key_github` 等用户入口追踪参数
|
||||
- [ ] 如未来调整追踪参数,统一评估 README、`packages/cli/README*`、`packages/core/README*` 与 package homepage,不要单点改造成不一致
|
||||
- [ ] grep `source_channel=`,确认每个残留都属于预期用户面文档或已批准的追踪入口
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
# 验证错误 hint 不再泄漏旧 URL
|
||||
HOME=/tmp/empty node packages/cli/src/main.ts text chat --message x --non-interactive
|
||||
HOME=/tmp/empty pnpm -F bailian-cli exec tsx src/main.ts text chat --message x
|
||||
# 看输出的 Get API Key URL 是否走新值
|
||||
|
||||
# 验证 banner / help
|
||||
node packages/cli/src/main.ts # banner
|
||||
node packages/cli/src/main.ts help # help 命令
|
||||
pnpm -F bailian-cli exec tsx src/main.ts # banner
|
||||
pnpm -F bailian-cli exec tsx src/main.ts help # help 命令
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 改了 `urls.ts` 但忘记同步 README(用户最先看到)
|
||||
- ✗ 在 cli 命令文件里 inline `https://bailian.console.aliyun.com/...` 而不是 `${API_KEY_PAGE}`
|
||||
- ✗ 改了 `urls.ts` / 登录站点 / 文档 deep link 但忘记同步 README(用户最先看到)
|
||||
- ✗ 在 runtime / command 文件里 inline `https://bailian.console.aliyun.com/...` 而不是从 `urls.ts` import
|
||||
- ✗ 在 core 的 hint 里写 URL(违反 [error-hint-change.md](error-hint-change.md) 不变量 1)
|
||||
|
||||
+3
-1
@@ -16,15 +16,17 @@
|
||||
"ready": "vp check && vp run -r test && vp run -r build",
|
||||
"prepare": "vp config",
|
||||
"check": "vp check",
|
||||
"sync:skill-assets": "pnpm --filter bailian-cli-core run build && pnpm --filter bailian-cli run generate:reference && pnpm --filter bailian-cli run sync:skill-version",
|
||||
"sync:skill-assets": "pnpm --filter bailian-cli run generate:reference && pnpm --filter bailian-cli run sync:skill-version",
|
||||
"dev": "pnpm -F bailian-cli-core dev",
|
||||
"bl": "pnpm -F bailian-cli dev",
|
||||
"kscli": "pnpm -F knowledge-studio-cli dev",
|
||||
"test": "vp test",
|
||||
"release:check": "node tools/release/check.mjs",
|
||||
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
|
||||
"test:stress": "node packages/cli/tests/stress/run.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+70
-22
@@ -5,11 +5,11 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
[Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [中文文档](https://github.com/modelstudioai/cli/blob/main/README.zh.md) · [API Documentation](https://help.aliyun.com/zh/model-studio/) · [Get API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,15 +27,20 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **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)
|
||||
- **Video generation & editing** — happyhorse-1.1 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
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
- **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
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Console capabilities** — Browse Bailian apps (`app list`) and check free-tier quota (`usage free`)
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
@@ -51,7 +56,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.0**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
@@ -64,7 +69,7 @@ A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.0** in parallel.
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
@@ -76,7 +81,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -87,6 +92,12 @@ bl auth login --console
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
@@ -108,9 +119,32 @@ bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
# Browser login (required for console capability commands)
|
||||
bl auth login --console
|
||||
|
||||
# Browse apps / free-tier quota
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -119,7 +153,7 @@ bl usage free --model qwen3-max
|
||||
|
||||
### DashScope API Key
|
||||
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cli?source_channel=key_github&).
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
@@ -132,21 +166,34 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`app list`, `usage free`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud AK/SK (Knowledge Base only)
|
||||
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
|
||||
|
||||
Required for `knowledge retrieve`. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Required for the `token-plan` command group. 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
|
||||
# Option 1: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# Option 2: Environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
@@ -159,7 +206,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
@@ -171,14 +218,15 @@ Config file location: `~/.bailian/config.json`
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
| :--------------------------- | :---------------------------------------------------------------- |
|
||||
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| 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/cli?source_channel=key_github& |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| Resource | URL |
|
||||
| :--------------------------- | :---------------------------------------------------------------------------------------- |
|
||||
| Aliyun Model Studio CLI Site | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| 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/?source_channel=cli_github |
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
|
||||
+74
-23
@@ -5,11 +5,11 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cli?source_channel=key_github&)
|
||||
[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&) · [English](https://github.com/modelstudioai/cli/blob/main/README.md) · [API 文档](https://help.aliyun.com/zh/model-studio/) · [获取 API Key](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,15 +27,20 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — HappyHorse-1.0 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **控制台能力** — 浏览百炼应用(`app list`),查询模型免费额度(`usage free`)
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
@@ -51,7 +56,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.0**,百炼的文生/图生/参考生视频模型
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
@@ -62,7 +67,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.0**。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
@@ -74,14 +79,23 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
@@ -103,9 +117,32 @@ bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
bl auth login --console
|
||||
|
||||
# 浏览应用 / 免费额度
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage free --model qwen3-max
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
@@ -114,7 +151,7 @@ bl usage free --model qwen3-max
|
||||
|
||||
### DashScope API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cli?source_channel=key_github&) 获取。
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
@@ -127,21 +164,34 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`app list`、`usage free`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 AK/SK(仅知识库检索)
|
||||
### 阿里云 OpenAPI AK/SK(仅 Token Plan)
|
||||
|
||||
`knowledge retrieve` 命令需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
```bash
|
||||
# 方式一:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# 方式二:环境变量
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
@@ -154,7 +204,7 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
bl config set --key region --value us
|
||||
bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
@@ -166,14 +216,15 @@ bl update
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| 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/cli?source_channel=key_github& |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
| 资源 | 地址 |
|
||||
| :---------------------- | :---------------------------------------------------------------------------------------- |
|
||||
| 阿里云百炼 CLI 官方主页 | https://bailian.console.aliyun.com/cli?source_channel=cli_github& |
|
||||
| DashScope API 文档 | https://help.aliyun.com/zh/model-studio/ |
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
|
||||
+16
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.2.1",
|
||||
"version": "1.10.1",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -33,42 +33,39 @@
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": "./dist/bailian.mjs",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:reference": "node --experimental-strip-types ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
|
||||
"sync:skill-version": "node --experimental-strip-types ../../tools/sync-skill-metadata.ts",
|
||||
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
|
||||
"sync:skill-version": "tsx ../../tools/sync-skill-metadata.ts",
|
||||
"build": "vp pack",
|
||||
"dev": "node src/main.ts",
|
||||
"dev": "tsx src/main.ts",
|
||||
"test": "vp test",
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-commands": "workspace:*",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:"
|
||||
"bailian-cli-runtime": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
||||
"ajv": "catalog:",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"e2e": "workspace:*",
|
||||
"typescript": "^6.0.2",
|
||||
"vite-plus": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"vite-plus": "0.1.22",
|
||||
"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"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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 buildAllowedFlagKeys(options: OptionDef[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const opt of options) {
|
||||
const key = flagKey(opt);
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
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 allowedKeys = buildAllowedFlagKeys(options);
|
||||
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 (!allowedKeys.has(camelKey)) {
|
||||
throw new BailianError(
|
||||
`Unknown flag "--${key}". Run with --help to see available options.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
// Switch-style flags (--quiet, --dry-run): no value. Value flags need a non-flag next token.
|
||||
if (schema.booleans.has(camelKey)) {
|
||||
(flags as Record<string, unknown>)[camelKey] = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --prompt <text>, --watermark <bool>, …
|
||||
if (value === undefined) {
|
||||
i++;
|
||||
const next = argv[i];
|
||||
if (next === undefined || next.startsWith("-")) {
|
||||
throw new BailianError(`Flag --${key} requires a value.`, ExitCode.USAGE);
|
||||
}
|
||||
value = next;
|
||||
}
|
||||
|
||||
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,14 @@
|
||||
import type { CommandPackPolicy } from "bailian-cli-runtime";
|
||||
|
||||
/** Command Packs accepted by the bl product. */
|
||||
export const commandPackPolicy = {
|
||||
supported: {
|
||||
"@ali/bailian-plugin-agent": {
|
||||
commandPrefixes: ["agent"],
|
||||
credentialAccess: ["apiKey"],
|
||||
},
|
||||
"@ali/bailian-plugin-inner-console-call": {
|
||||
commandPrefixes: ["inner-console"],
|
||||
},
|
||||
},
|
||||
} as const satisfies CommandPackPolicy;
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { AnyCommand } from "bailian-cli-core";
|
||||
import {
|
||||
authLogin,
|
||||
authStatus,
|
||||
authLogout,
|
||||
authGenerateAccessToken,
|
||||
textChat,
|
||||
textOmni,
|
||||
imageGenerate,
|
||||
imageEdit,
|
||||
videoGenerate,
|
||||
videoEdit,
|
||||
videoRef,
|
||||
videoTaskGet,
|
||||
videoDownload,
|
||||
visionDescribe,
|
||||
configShow,
|
||||
configSet,
|
||||
configList,
|
||||
configUse,
|
||||
configUi,
|
||||
configAgent,
|
||||
update,
|
||||
appCall,
|
||||
appList,
|
||||
memoryAdd,
|
||||
memorySearch,
|
||||
memoryList,
|
||||
memoryUpdate,
|
||||
memoryDelete,
|
||||
memoryProfileCreate,
|
||||
memoryProfileGet,
|
||||
knowledgeRetrieve,
|
||||
knowledgeSearch,
|
||||
knowledgeChat,
|
||||
mcpCall,
|
||||
mcpList,
|
||||
mcpTools,
|
||||
searchWeb,
|
||||
speechSynthesize,
|
||||
speechRecognize,
|
||||
fileUpload,
|
||||
consoleCall,
|
||||
usageFree,
|
||||
usageFreetier,
|
||||
usageStats,
|
||||
usageSummary,
|
||||
pipelineRun,
|
||||
pipelineValidate,
|
||||
advisorRecommend,
|
||||
modelList,
|
||||
workspaceList,
|
||||
quotaList,
|
||||
quotaRequest,
|
||||
quotaHistory,
|
||||
quotaCheck,
|
||||
datasetUpload,
|
||||
datasetList,
|
||||
datasetGet,
|
||||
datasetDelete,
|
||||
datasetValidate,
|
||||
finetuneTextCreate,
|
||||
finetuneAudioCreate,
|
||||
finetuneImageCreate,
|
||||
finetuneList,
|
||||
finetuneGet,
|
||||
finetuneCancel,
|
||||
finetuneDelete,
|
||||
finetuneLogs,
|
||||
finetuneCheckpoints,
|
||||
finetuneExport,
|
||||
finetuneWatch,
|
||||
finetuneCapability,
|
||||
deployTextCreate,
|
||||
deployAudioCreate,
|
||||
deployImageCreate,
|
||||
deployList,
|
||||
deployGet,
|
||||
deployModels,
|
||||
deployScale,
|
||||
deployUpdate,
|
||||
deployDelete,
|
||||
tokenPlanListSeats,
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
tokenPlanAddMember,
|
||||
workspaceInit,
|
||||
pluginInstall,
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
// The command paths below are this product's decision — the command library
|
||||
// ships no presets, so the map is spelled out here. Kept in its own module
|
||||
// (no side effects) so tools like generate-reference.ts can import it without
|
||||
// starting the CLI.
|
||||
export const commands: Record<string, AnyCommand> = {
|
||||
"auth login": authLogin,
|
||||
"auth status": authStatus,
|
||||
"auth logout": authLogout,
|
||||
"auth generate-access-token": authGenerateAccessToken,
|
||||
"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,
|
||||
"config show": configShow,
|
||||
"config set": configSet,
|
||||
"config list": configList,
|
||||
"config use": configUse,
|
||||
"config ui": configUi,
|
||||
"config agent": configAgent,
|
||||
update,
|
||||
"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,
|
||||
"knowledge search": knowledgeSearch,
|
||||
"knowledge chat": knowledgeChat,
|
||||
"mcp call": mcpCall,
|
||||
"mcp list": mcpList,
|
||||
"mcp tools": mcpTools,
|
||||
"search web": searchWeb,
|
||||
"speech synthesize": speechSynthesize,
|
||||
"speech recognize": speechRecognize,
|
||||
"file upload": fileUpload,
|
||||
"console call": consoleCall,
|
||||
"usage free": usageFree,
|
||||
"usage freetier": usageFreetier,
|
||||
"usage stats": usageStats,
|
||||
"usage summary": usageSummary,
|
||||
"pipeline run": pipelineRun,
|
||||
"pipeline validate": pipelineValidate,
|
||||
"advisor recommend": advisorRecommend,
|
||||
"model list": modelList,
|
||||
"workspace list": workspaceList,
|
||||
"quota list": quotaList,
|
||||
"quota request": quotaRequest,
|
||||
"quota history": quotaHistory,
|
||||
"quota check": quotaCheck,
|
||||
"dataset upload": datasetUpload,
|
||||
"dataset list": datasetList,
|
||||
"dataset get": datasetGet,
|
||||
"dataset delete": datasetDelete,
|
||||
"dataset validate": datasetValidate,
|
||||
"finetune text create": finetuneTextCreate,
|
||||
"finetune audio create": finetuneAudioCreate,
|
||||
"finetune image create": finetuneImageCreate,
|
||||
"finetune list": finetuneList,
|
||||
"finetune get": finetuneGet,
|
||||
"finetune cancel": finetuneCancel,
|
||||
"finetune delete": finetuneDelete,
|
||||
"finetune logs": finetuneLogs,
|
||||
"finetune checkpoints": finetuneCheckpoints,
|
||||
"finetune export": finetuneExport,
|
||||
"finetune watch": finetuneWatch,
|
||||
"finetune capability": finetuneCapability,
|
||||
"deploy text create": deployTextCreate,
|
||||
"deploy audio create": deployAudioCreate,
|
||||
"deploy image create": deployImageCreate,
|
||||
"deploy list": deployList,
|
||||
"deploy get": deployGet,
|
||||
"deploy models": deployModels,
|
||||
"deploy scale": deployScale,
|
||||
"deploy update": deployUpdate,
|
||||
"deploy delete": deployDelete,
|
||||
"token-plan list-seats": tokenPlanListSeats,
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
"token-plan add-member": tokenPlanAddMember,
|
||||
"workspace init": workspaceInit,
|
||||
"plugin install": pluginInstall,
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
};
|
||||
@@ -1,321 +0,0 @@
|
||||
import {
|
||||
analyzeIntent,
|
||||
buildDocLink,
|
||||
type Config,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
type GetModelsOptions,
|
||||
type GlobalFlags,
|
||||
getModels,
|
||||
type IntentProfile,
|
||||
isInteractive,
|
||||
type PipelineStep,
|
||||
type RecommendedModel,
|
||||
type RecommendResult,
|
||||
rankModels,
|
||||
recallSemantic,
|
||||
} from "bailian-cli-core";
|
||||
import boxen from "boxen";
|
||||
import chalk, { Chalk, type ChalkInstance } from "chalk";
|
||||
import { emitBare, emitResult } from "../../output/output.ts";
|
||||
import { createSpinner } from "../../output/progress.ts";
|
||||
import { failIfMissing, promptText } from "../../output/prompt.ts";
|
||||
|
||||
function formatContextWindow(tokens: number): string {
|
||||
if (tokens >= 1_000_000)
|
||||
return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens % 1_000 === 0 ? 0 : 1)}K`;
|
||||
return String(tokens);
|
||||
}
|
||||
|
||||
const MODALITY_LABELS: Record<string, string> = {
|
||||
Text: "文本",
|
||||
Image: "图片",
|
||||
Video: "视频",
|
||||
Audio: "音频",
|
||||
};
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
TG: "文本生成",
|
||||
VU: "视觉理解",
|
||||
IG: "图像生成",
|
||||
VG: "视频生成",
|
||||
TTS: "语音合成",
|
||||
ASR: "语音识别",
|
||||
Reasoning: "推理",
|
||||
};
|
||||
const BUDGET_LABELS: Record<string, string> = {
|
||||
low: "低成本优先",
|
||||
medium: "适中",
|
||||
high: "高投入",
|
||||
};
|
||||
const QUALITY_LABELS: Record<string, string> = {
|
||||
flagship: "旗舰优先",
|
||||
balanced: "均衡",
|
||||
"cost-optimized": "性价比优先",
|
||||
};
|
||||
const PREFERENCE_MODE_LABELS: Record<string, string> = {
|
||||
scoped: "限定范围",
|
||||
comparison: "对比评估",
|
||||
alternative: "替代推荐",
|
||||
};
|
||||
|
||||
function formatIntentSummary(intent: IntentProfile, noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorize.cyan.bold("需求理解"));
|
||||
|
||||
if (intent.taskSummary) {
|
||||
lines.push("");
|
||||
lines.push(intent.taskSummary);
|
||||
}
|
||||
|
||||
if (intent.scenarioHints.length) {
|
||||
lines.push("");
|
||||
lines.push(`${colorize.dim("场景特征")} ${intent.scenarioHints.join(" · ")}`);
|
||||
}
|
||||
|
||||
const inputLabels = intent.inputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
|
||||
const outputLabels = intent.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod);
|
||||
if (inputLabels.length || outputLabels.length) {
|
||||
lines.push("");
|
||||
const parts: string[] = [];
|
||||
if (inputLabels.length) parts.push(`${colorize.dim("输入")} ${inputLabels.join(", ")}`);
|
||||
if (outputLabels.length) parts.push(`${colorize.dim("输出")} ${outputLabels.join(", ")}`);
|
||||
lines.push(parts.join(" "));
|
||||
}
|
||||
|
||||
const capLabels = intent.requiredCapabilities.map((cap) => CAPABILITY_LABELS[cap] ?? cap);
|
||||
if (capLabels.length) {
|
||||
lines.push(`${colorize.dim("所需能力")} ${capLabels.join(", ")}`);
|
||||
}
|
||||
|
||||
const budgetLabel = BUDGET_LABELS[intent.budget] ?? intent.budget;
|
||||
const qualityLabel = QUALITY_LABELS[intent.qualityPreference] ?? intent.qualityPreference;
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${colorize.dim("预算倾向")} ${budgetLabel} ${colorize.dim("质量偏好")} ${qualityLabel}`,
|
||||
);
|
||||
|
||||
const preference = intent.modelPreference;
|
||||
if (preference && preference.mode !== "unconstrained") {
|
||||
lines.push("");
|
||||
const modeLabel = PREFERENCE_MODE_LABELS[preference.mode] ?? preference.mode;
|
||||
const prefParts = [colorize.dim("推荐模式") + ` ${colorize.yellow(modeLabel)}`];
|
||||
if (preference.targets?.length) {
|
||||
prefParts.push(colorize.dim("目标") + ` ${preference.targets.join(", ")}`);
|
||||
}
|
||||
if (preference.excludes?.length) {
|
||||
prefParts.push(colorize.dim("排除") + ` ${preference.excludes.join(", ")}`);
|
||||
}
|
||||
lines.push(prefParts.join(" "));
|
||||
}
|
||||
|
||||
if (intent.segments?.length) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim("任务拆解"));
|
||||
for (const [idx, segment] of intent.segments.entries()) {
|
||||
const outMods = segment.outputModality.map((mod) => MODALITY_LABELS[mod] ?? mod).join(", ");
|
||||
lines.push(
|
||||
` ${colorize.dim(`${idx + 1}.`)} ${segment.step}${outMods ? colorize.dim(` → ${outMods}`) : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return boxen(lines.join("\n"), {
|
||||
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
||||
margin: { top: 0, bottom: 0, left: 1, right: 0 },
|
||||
borderColor: "cyan",
|
||||
borderStyle: "round",
|
||||
dimBorder: true,
|
||||
});
|
||||
}
|
||||
|
||||
const RECOMMEND_LABELS = ["最佳推荐", "次优选择", "备选参考"];
|
||||
|
||||
function renderCard(rec: RecommendedModel, index: number, colorize: ChalkInstance): string {
|
||||
const labelColors = [colorize.green.bold, colorize.blue.bold, colorize.magenta.bold];
|
||||
const colorFn = labelColors[index] ?? colorize.white.bold;
|
||||
const label = RECOMMEND_LABELS[index] ?? `推荐 #${index + 1}`;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(colorFn(`⬢ 推荐 #${index + 1} — ${label}`));
|
||||
lines.push("");
|
||||
lines.push(`${colorize.bold(rec.name)} ${colorize.dim(`(${rec.model})`)}`);
|
||||
lines.push("");
|
||||
lines.push(`${colorize.cyan("推荐理由")} ${rec.reason}`);
|
||||
|
||||
if (rec.highlights.length) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
rec.highlights.map((highlight) => colorize.bgGray.white(` ${highlight} `)).join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (rec.contextWindow) meta.push(`上下文 ${formatContextWindow(rec.contextWindow)}`);
|
||||
if (rec.maxOutputTokens) meta.push(`最大输出 ${formatContextWindow(rec.maxOutputTokens)}`);
|
||||
if (meta.length) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim(meta.join(" · ")));
|
||||
}
|
||||
|
||||
const docLink = buildDocLink(rec.docUrl);
|
||||
if (docLink) {
|
||||
lines.push("");
|
||||
lines.push(colorize.dim(`文档 ${docLink}`));
|
||||
}
|
||||
|
||||
return boxen(lines.join("\n"), {
|
||||
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
||||
margin: { top: 0, bottom: 0, left: 1, right: 0 },
|
||||
borderColor: "gray",
|
||||
borderStyle: "round",
|
||||
dimBorder: true,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSingleResult(results: RecommendedModel[], noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
return results.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n");
|
||||
}
|
||||
|
||||
function formatPipelineResult(summary: string, steps: PipelineStep[], noColor: boolean): string {
|
||||
const colorize = noColor ? new Chalk({ level: 0 }) : chalk;
|
||||
const lines: string[] = [];
|
||||
lines.push(` ${colorize.yellow.bold("⚡ 组合方案")} ${summary}`);
|
||||
|
||||
for (const [stepIdx, { step, recommendations, warnings }] of steps.entries()) {
|
||||
lines.push("");
|
||||
lines.push(colorize.bold(` ━━━ Step ${stepIdx + 1}: ${step} ━━━`));
|
||||
|
||||
if (warnings?.length) {
|
||||
for (const warning of warnings) {
|
||||
lines.push(` ${colorize.yellow("⚠")} ${colorize.yellow(warning)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(recommendations.map((rec, idx) => renderCard(rec, idx, colorize)).join("\n"));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatResult(result: RecommendResult, noColor: boolean): string {
|
||||
if (result.type === "pipeline") {
|
||||
return formatPipelineResult(result.summary, result.steps, noColor);
|
||||
}
|
||||
return formatSingleResult(result.recommendations, noColor);
|
||||
}
|
||||
|
||||
function isEmptyResult(result: RecommendResult): boolean {
|
||||
if (result.type === "pipeline") return result.steps.length === 0;
|
||||
return result.recommendations.length === 0;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "advisor recommend",
|
||||
description:
|
||||
"Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking)",
|
||||
usage: "bl advisor recommend <prompt> [flags]",
|
||||
options: [
|
||||
{
|
||||
flag: "--message <text>",
|
||||
description: "Describe your requirements (alternative to positional prompt)",
|
||||
},
|
||||
{
|
||||
flag: "--dry-run",
|
||||
description: "Show intent analysis and candidate list without LLM ranking",
|
||||
},
|
||||
{
|
||||
flag: "--output <format>",
|
||||
description: "Output format: text (default in TTY), json, yaml",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
'bl advisor recommend --message "我要做一个能理解图片的客服机器人"',
|
||||
'bl advisor recommend --message "做一个Agent自动根据用户意图生成动画片"',
|
||||
'bl advisor recommend --message "法律合同审查,要求高精准度"',
|
||||
'bl advisor recommend --message "做一个低成本高并发的在线客服" --output json',
|
||||
'bl advisor recommend --message "长文本摘要" --dry-run',
|
||||
"bl advisor recommend # 交互式输入需求",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional = ((flags as Record<string, unknown>)._positional as string[]) ?? [];
|
||||
let userInput = (flags.message as string) || positional.join(" ");
|
||||
|
||||
if (!userInput.trim()) {
|
||||
if (isInteractive({ nonInteractive: config.nonInteractive })) {
|
||||
const hint = await promptText({ message: "描述你的需求:" });
|
||||
if (!hint) {
|
||||
process.stderr.write("已取消。\n");
|
||||
process.exit(1);
|
||||
}
|
||||
userInput = hint;
|
||||
} else {
|
||||
failIfMissing("message", 'bl advisor recommend "你的需求"');
|
||||
}
|
||||
}
|
||||
|
||||
const top = 3;
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
const modelsOptions: GetModelsOptions = {
|
||||
onPrepareStart: () => process.stderr.write("初始化中...\n"),
|
||||
};
|
||||
process.stderr.write("正在分析需求...\n");
|
||||
const [allModels, intent] = await Promise.all([
|
||||
getModels(config, modelsOptions),
|
||||
analyzeIntent(config, userInput),
|
||||
]);
|
||||
|
||||
if (intent.confidence === 0) {
|
||||
process.stderr.write("需求分析超时,使用默认参数继续...\n");
|
||||
} else {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
|
||||
// Stage 2: Candidate Recall (semantic recall, auto-builds embeddings on first run)
|
||||
const candidates = await recallSemantic(config, allModels, userInput, 50, intent);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
userInput,
|
||||
intent,
|
||||
candidateCount: candidates.length,
|
||||
candidates: candidates.map(({ model, score }) => ({
|
||||
model: model.model,
|
||||
score,
|
||||
})),
|
||||
top,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage 3: LLM Ranking
|
||||
const spinner = createSpinner("正在推荐最佳模型...");
|
||||
spinner.start();
|
||||
|
||||
const result = await rankModels(config, candidates, intent, userInput, top);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (isEmptyResult(result)) {
|
||||
emitBare("暂无满足该需求的模型。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (format !== "text") {
|
||||
emitResult(result, format);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(formatIntentSummary(intent, config.noColor));
|
||||
emitBare("");
|
||||
emitBare(formatResult(result, config.noColor));
|
||||
},
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
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";
|
||||
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
|
||||
|
||||
const RETRY_DELAY_BASE_MS = 500;
|
||||
|
||||
function canRetry(err: unknown): boolean {
|
||||
if (err instanceof BailianError) {
|
||||
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) {
|
||||
return true;
|
||||
}
|
||||
const status = err.api?.httpStatus;
|
||||
return status === 401 || (status !== undefined && status >= 500);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return (
|
||||
err.name === "AbortError" ||
|
||||
err.name === "TimeoutError" ||
|
||||
err.message.includes("timed out") ||
|
||||
err.message === "fetch failed"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function validateKeyAndPersist(config: Config, key: string): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const testConfig = { ...config, apiKey: key };
|
||||
const requestOpts = {
|
||||
url: chatEndpoint(testConfig.baseUrl),
|
||||
method: "POST",
|
||||
timeout: Math.min(config.timeout, 30),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await requestJson<unknown>(testConfig, requestOpts);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt >= 3 || !canRetry(err)) {
|
||||
process.stderr.write("\n");
|
||||
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
// retry delay: 500ms, 1000ms, 2000ms
|
||||
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const hasApiKey = !!(config.apiKey || config.fileApiKey);
|
||||
await runConsoleLogin(resolveConsoleOrigin(), {
|
||||
needApiKey: !hasApiKey,
|
||||
onApiKey: (key) => validateKeyAndPersist(config, key),
|
||||
});
|
||||
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.");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
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 mcpCall from "./mcp/call.ts";
|
||||
import mcpList from "./mcp/list.ts";
|
||||
import mcpTools from "./mcp/tools.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";
|
||||
import advisorRecommend from "./advisor/recommend.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,
|
||||
"mcp list": mcpList,
|
||||
"mcp tools": mcpTools,
|
||||
"mcp call": mcpCall,
|
||||
"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,
|
||||
"advisor recommend": advisorRecommend,
|
||||
update: update,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
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");
|
||||
},
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
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",
|
||||
];
|
||||
|
||||
// Keys whose values are secrets. Their stored value must never be echoed back in
|
||||
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
|
||||
// same policy `config show` and `auth status` already follow.
|
||||
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
|
||||
|
||||
// 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) {
|
||||
const shown = SECRET_KEYS.has(resolvedKey)
|
||||
? maskToken(String(existing[resolvedKey]))
|
||||
: existing[resolvedKey];
|
||||
emitResult({ [resolvedKey]: shown }, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
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 qwen3-vl-plus",
|
||||
"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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
requestJson,
|
||||
imageSyncEndpoint,
|
||||
detectOutputFormat,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
resolveCredential,
|
||||
resolveFileUrl,
|
||||
resolveOutputDir,
|
||||
generateFilename,
|
||||
isInteractive,
|
||||
stripUndefined,
|
||||
type DashScopeImageRequest,
|
||||
type DashScopeImageSyncResponse,
|
||||
ExitCode,
|
||||
BailianError,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} 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";
|
||||
import {
|
||||
BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.ts";
|
||||
|
||||
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 <bool>",
|
||||
description: BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
|
||||
},
|
||||
{
|
||||
flag: "--watermark <bool>",
|
||||
description: BOOL_FLAG_WATERMARK,
|
||||
},
|
||||
{ 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',
|
||||
'bl image edit --image ./photo.png --prompt "把背景换成海滩" --watermark false',
|
||||
],
|
||||
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;
|
||||
|
||||
const promptExtend = resolveBooleanFlag(flags.promptExtend, true, "prompt-extend");
|
||||
|
||||
// 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 watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
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,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} 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 {
|
||||
BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.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 <bool>",
|
||||
description: BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE,
|
||||
},
|
||||
{
|
||||
flag: "--watermark <bool>",
|
||||
description: BOOL_FLAG_WATERMARK,
|
||||
},
|
||||
{
|
||||
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 --prompt-extend false',
|
||||
'bl image generate --prompt "Logo" --watermark false',
|
||||
'bl image generate --prompt "An alien in the space" --watermark false',
|
||||
'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);
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt! }] }],
|
||||
},
|
||||
parameters: {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed as number | undefined,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: (flags.negativePrompt as string) || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.quiet) {
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
|
||||
}
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(config, model, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(config, model, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Sync mode: qwen-image-2.0 series ----
|
||||
|
||||
async function handleSyncMode(
|
||||
config: Config,
|
||||
_model: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GlobalFlags,
|
||||
format: string,
|
||||
concurrent: number,
|
||||
): Promise<void> {
|
||||
const url = imageSyncEndpoint(config.baseUrl);
|
||||
|
||||
const results = await runConcurrent(concurrent, config, () =>
|
||||
requestJson<DashScopeImageSyncResponse>(config, { url, method: "POST", body }),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new BailianError("Generation completed but no images returned.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
await saveImages(imageUrls, flags, config, format);
|
||||
}
|
||||
|
||||
// ---- Async mode: wan2.x / qwen-image-plus ----
|
||||
|
||||
async function handleAsyncMode(
|
||||
config: Config,
|
||||
_model: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GlobalFlags,
|
||||
format: string,
|
||||
concurrent: number,
|
||||
): Promise<void> {
|
||||
const url = imageEndpoint(config.baseUrl);
|
||||
|
||||
const responses = await runConcurrent(
|
||||
concurrent,
|
||||
config,
|
||||
() => requestJson<DashScopeAsyncResponse>(config, { url, method: "POST", body, async: true }),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
|
||||
// --no-wait: return all task IDs immediately
|
||||
if (flags.noWait || config.async) {
|
||||
emitResult({ task_ids: taskIds }, format as OutputFormat);
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll all tasks concurrently
|
||||
const pollInterval = (flags.pollInterval as number) ?? 3;
|
||||
|
||||
const pollPromises = taskIds.map((taskId) => {
|
||||
const pollUrl = taskEndpoint(config.baseUrl, taskId);
|
||||
return poll<DashScopeTaskResponse>(config, {
|
||||
url: pollUrl,
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: config.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(pollPromises);
|
||||
|
||||
let imageUrls: string[] = [];
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new BailianError("All tasks completed but no images returned.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
await saveImages(
|
||||
imageUrls,
|
||||
flags,
|
||||
config,
|
||||
format,
|
||||
taskIds.length === 1 ? taskIds[0] : undefined,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Shared: download & save ----
|
||||
|
||||
async function saveImages(
|
||||
imageUrls: string[],
|
||||
flags: GlobalFlags,
|
||||
config: Config,
|
||||
format: string,
|
||||
taskId?: string,
|
||||
taskIds?: string[],
|
||||
): Promise<void> {
|
||||
const outDir = resolveOutputDir(config, {
|
||||
flagDir: flags.outDir as string | undefined,
|
||||
subDir: flags.outDir ? undefined : "images",
|
||||
});
|
||||
|
||||
const promptText =
|
||||
(flags.prompt as string) || (flags._positional as string[] | undefined)?.[0] || "";
|
||||
const prefix = (flags.outPrefix as string) || generateFilename("image", promptText);
|
||||
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
const results = await downloadParallel(items, downloadFile, { quiet: config.quiet });
|
||||
|
||||
if (config.quiet) {
|
||||
emitBare(results.join("\n"));
|
||||
} else {
|
||||
const output: Record<string, unknown> = {
|
||||
urls: imageUrls,
|
||||
saved: results,
|
||||
total: imageUrls.length,
|
||||
};
|
||||
if (taskId) output.task_id = taskId;
|
||||
if (taskIds && taskIds.length > 1) output.task_ids = taskIds;
|
||||
emitResult(output, format as OutputFormat);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { commands } from "./catalog.ts";
|
||||
@@ -1,155 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
signRequest,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
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: ${maskToken(accessKeyId)}\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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
McpClient,
|
||||
bailianMcpUrl,
|
||||
detectOutputFormat,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { ensureApiKey } from "../../utils/ensure-key.ts";
|
||||
|
||||
function parseArgFlags(raw: string[]): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const item of raw) {
|
||||
const idx = item.indexOf("=");
|
||||
if (idx <= 0) {
|
||||
process.stderr.write(`Error: --arg must be in K=V form, got: ${item}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const key = item.slice(0, idx).trim();
|
||||
const rawVal = item.slice(idx + 1);
|
||||
try {
|
||||
out[key] = JSON.parse(rawVal);
|
||||
} catch {
|
||||
out[key] = rawVal;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
name: "mcp call",
|
||||
description: "Call a tool on an MCP server (tools/call)",
|
||||
usage: "bl mcp call <server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
|
||||
options: [
|
||||
{
|
||||
flag: "<server-code>.<tool>",
|
||||
description:
|
||||
"Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
flag: "--arg <kv>",
|
||||
description: "Tool argument (repeatable). Values parsed as JSON if possible, else string.",
|
||||
type: "array",
|
||||
},
|
||||
{
|
||||
flag: "--json <obj>",
|
||||
description: "Full arguments object as JSON; merged with --arg (arg wins).",
|
||||
},
|
||||
{
|
||||
flag: "--query <text>",
|
||||
description: "Shortcut for --arg query=<text> (mirrors many DashScope MCP tools).",
|
||||
},
|
||||
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
|
||||
],
|
||||
examples: [
|
||||
'bl mcp call market-cmapi00073529.SmartStockSelection --query "筛选ROE>15%的消费股"',
|
||||
'bl mcp call market-cmapi00073529.FinQuery --json \'{"q":"贵州茅台","limit":5}\'',
|
||||
"bl mcp call market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional =
|
||||
((flags as Record<string, unknown>)._positional as string[] | undefined) ?? [];
|
||||
const target = positional[0];
|
||||
if (!target) failIfMissing("<server-code>.<tool>", "bl mcp call <server-code>.<tool>");
|
||||
|
||||
const dot = target!.indexOf(".");
|
||||
if (dot <= 0 || dot === target!.length - 1) {
|
||||
process.stderr.write(`Error: target must be <server-code>.<tool>, got "${target}".\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const serverCode = target!.slice(0, dot);
|
||||
const toolName = target!.slice(dot + 1);
|
||||
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (flags.json) {
|
||||
try {
|
||||
const parsed = JSON.parse(flags.json as string);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
process.stderr.write("Error: --json must decode to an object.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
toolArgs = parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: --json is not valid JSON — ${(err as Error).message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Object.assign(toolArgs, parseArgFlags((flags.arg as string[] | undefined) ?? []));
|
||||
if (flags.query !== undefined) toolArgs.query = flags.query;
|
||||
|
||||
const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, serverCode);
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
server: serverCode,
|
||||
url,
|
||||
tool: toolName,
|
||||
arguments: toolArgs,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureApiKey(config);
|
||||
const client = new McpClient(config, url);
|
||||
await client.initialize();
|
||||
const result = await client.callTool(toolName, toolArgs);
|
||||
|
||||
if (result.isError) {
|
||||
const errText = result.content.map((c) => c.text || "").join("\n");
|
||||
process.stderr.write(`Tool error: ${errText}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
emitResult(result, format);
|
||||
},
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
McpClient,
|
||||
bailianMcpUrl,
|
||||
detectOutputFormat,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { failIfMissing } from "../../output/prompt.ts";
|
||||
import { emitResult } from "../../output/output.ts";
|
||||
import { ensureApiKey } from "../../utils/ensure-key.ts";
|
||||
|
||||
export default defineCommand({
|
||||
name: "mcp tools",
|
||||
description: "List tools exposed by an MCP server (tools/list)",
|
||||
usage: "bl mcp tools <server-code> [--url <url>]",
|
||||
options: [
|
||||
{
|
||||
flag: "<server-code>",
|
||||
description: "Server code from `bl mcp list` (e.g. market-cmapi00073529)",
|
||||
required: true,
|
||||
},
|
||||
{ flag: "--url <url>", description: "Override the MCP endpoint URL (for non-Bailian servers)" },
|
||||
],
|
||||
examples: [
|
||||
"bl mcp tools market-cmapi00073529",
|
||||
"bl mcp tools market-cmapi00073529 --output json",
|
||||
"bl mcp tools my-server --url https://example.com/mcp",
|
||||
],
|
||||
async run(config: Config, flags: GlobalFlags) {
|
||||
const positional =
|
||||
((flags as Record<string, unknown>)._positional as string[] | undefined) ?? [];
|
||||
const code = positional[0];
|
||||
if (!code) failIfMissing("server-code", "bl mcp tools <server-code>");
|
||||
|
||||
const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, code!);
|
||||
const format = detectOutputFormat(config.output);
|
||||
|
||||
if (config.dryRun) {
|
||||
emitResult({ server: code, url, action: "tools/list" }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureApiKey(config);
|
||||
const client = new McpClient(config, url);
|
||||
await client.initialize();
|
||||
const tools = await client.listTools();
|
||||
emitResult({ server: code, url, tools }, format);
|
||||
},
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
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";
|
||||
|
||||
const SKILL_NAME = "bailian-cli";
|
||||
|
||||
/** Build the install command */
|
||||
function detectInstallCommand(): { cmd: string; label: string } {
|
||||
return { cmd: `npm install -g ${NPM_PACKAGE}@latest`, label: "npm" };
|
||||
}
|
||||
|
||||
function updateAgentSkill(colors: { green: string; yellow: string; reset: string }): void {
|
||||
const { green, yellow, reset } = colors;
|
||||
process.stderr.write("\nUpdating agent skill...\n");
|
||||
try {
|
||||
execSync(`npx skills update ${SKILL_NAME} -g -y`, { stdio: "inherit" });
|
||||
process.stderr.write(`${green}\u2713 Agent skill updated.${reset}\n`);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`${yellow}Agent skill update skipped. Run manually: npx skills update ${SKILL_NAME} -g -y${reset}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
updateAgentSkill({ green, yellow, reset });
|
||||
} catch {
|
||||
process.stderr.write("\nAutomatic update failed. Please run manually:\n");
|
||||
process.stderr.write(` ${cmd}\n\n`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
requestJson,
|
||||
videoGenerateEndpoint,
|
||||
taskEndpoint,
|
||||
detectOutputFormat,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type DashScopeVideoEditRequest,
|
||||
type DashScopeAsyncResponse,
|
||||
type DashScopeTaskResponse,
|
||||
isInteractive,
|
||||
resolveOutputDir,
|
||||
resolveFileUrl,
|
||||
resolveCredential,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} 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";
|
||||
import {
|
||||
BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.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 <bool>",
|
||||
description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
|
||||
},
|
||||
{
|
||||
flag: "--watermark <bool>",
|
||||
description: BOOL_FLAG_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',
|
||||
'bl video edit --video https://example.com/input.mp4 --prompt "给视频里的小猫穿上衣服" --watermark false',
|
||||
],
|
||||
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 = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
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,
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
requestJson,
|
||||
videoGenerateEndpoint,
|
||||
taskEndpoint,
|
||||
detectOutputFormat,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type DashScopeVideoRequest,
|
||||
type DashScopeAsyncResponse,
|
||||
type DashScopeTaskResponse,
|
||||
isInteractive,
|
||||
resolveOutputDir,
|
||||
resolveFileUrl,
|
||||
resolveCredential,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} 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";
|
||||
import {
|
||||
BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.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 <bool>",
|
||||
description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
|
||||
},
|
||||
{
|
||||
flag: "--watermark <bool>",
|
||||
description: BOOL_FLAG_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: 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',
|
||||
'bl video generate --prompt "A cat playing with a ball" --watermark false',
|
||||
],
|
||||
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 watermark = resolveWatermark(flags.watermark);
|
||||
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
|
||||
|
||||
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: promptExtend,
|
||||
watermark,
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
import {
|
||||
defineCommand,
|
||||
requestJson,
|
||||
videoGenerateEndpoint,
|
||||
taskEndpoint,
|
||||
detectOutputFormat,
|
||||
type Config,
|
||||
type GlobalFlags,
|
||||
type DashScopeVideoRefRequest,
|
||||
type DashScopeAsyncResponse,
|
||||
type DashScopeTaskResponse,
|
||||
isInteractive,
|
||||
resolveOutputDir,
|
||||
resolveFileUrl,
|
||||
resolveCredential,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
} 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";
|
||||
import {
|
||||
BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
|
||||
BOOL_FLAG_WATERMARK,
|
||||
} from "../../utils/flag-descriptions.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 <bool>",
|
||||
description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
|
||||
},
|
||||
{
|
||||
flag: "--watermark <bool>",
|
||||
description: BOOL_FLAG_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 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',
|
||||
'bl video ref --prompt "图1在喝水" --image person.jpg --watermark false',
|
||||
],
|
||||
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 = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
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,
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
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: qwen3-vl-plus)" },
|
||||
],
|
||||
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) || "qwen3-vl-plus";
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
+18
-179
@@ -1,181 +1,20 @@
|
||||
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";
|
||||
import { createCli } from "bailian-cli-runtime";
|
||||
import { commands } from "./commands.ts";
|
||||
import { commandPackPolicy } from "./command-pack-policy.ts";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
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);
|
||||
});
|
||||
const quickStartTasks = [
|
||||
"Help me generate a set of Amazon e-commerce main images for baseball caps (white background + lifestyle shots + model wear shots)",
|
||||
"Help me generate a 3-minute humorous crosstalk audio clip",
|
||||
"Help me generate a Little Red Riding Hood picture-book PDF (with illustrations)",
|
||||
"Help me analyze this video and write a Xiaohongshu-style post",
|
||||
] as const;
|
||||
|
||||
// 优雅处理 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"],
|
||||
["mcp", "list"],
|
||||
["mcp", "tools"],
|
||||
["mcp", "call"],
|
||||
];
|
||||
|
||||
async function main() {
|
||||
let argv = process.argv.slice(2);
|
||||
if (argv[0] === "--") argv = argv.slice(1);
|
||||
|
||||
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));
|
||||
});
|
||||
void createCli(commands, {
|
||||
binName: "bl",
|
||||
version: pkg.version,
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
quickStartTasks,
|
||||
commandPacks: commandPackPolicy,
|
||||
}).run();
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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`,
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
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);
|
||||
@@ -1,25 +0,0 @@
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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`);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
|
||||
|
||||
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
||||
export const NPM_PACKAGE = "bailian-cli";
|
||||
|
||||
const STATE_FILE = () => join(getConfigDir(), "update-state.json");
|
||||
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4h
|
||||
const FETCH_TIMEOUT_MS = 3000;
|
||||
|
||||
/**
|
||||
* Simple semver comparison: returns true if a > b.
|
||||
* Supports standard x.y.z format.
|
||||
*/
|
||||
function isNewerVersion(a: string, b: string): boolean {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
||||
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
||||
}
|
||||
return false; // equal
|
||||
}
|
||||
|
||||
interface UpdateState {
|
||||
lastChecked: number;
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
function readState(): UpdateState | null {
|
||||
try {
|
||||
const raw = readFileSync(STATE_FILE(), "utf-8");
|
||||
return JSON.parse(raw) as UpdateState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state: UpdateState): void {
|
||||
try {
|
||||
writeFileSync(STATE_FILE(), JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLatestVersion(
|
||||
timeoutMs: number = FETCH_TIMEOUT_MS,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const encoded = NPM_PACKAGE.replace("/", "%2f");
|
||||
const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...trackingHeaders(),
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { version?: string };
|
||||
return data.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingNotification: string | null = null;
|
||||
|
||||
export function getPendingUpdateNotification(): string | null {
|
||||
return pendingNotification;
|
||||
}
|
||||
|
||||
export async function checkForUpdate(currentVersion: string): Promise<void> {
|
||||
// Skip in CI / non-TTY environments
|
||||
if (process.env.CI || !process.stderr.isTTY) return;
|
||||
|
||||
const state = readState();
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle: skip if checked within the last 4 hours
|
||||
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) {
|
||||
if (state.latestVersion && isNewerVersion(state.latestVersion, currentVersion)) {
|
||||
pendingNotification = state.latestVersion;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = await fetchLatestVersion();
|
||||
if (!latest) return;
|
||||
|
||||
writeState({ lastChecked: now, latestVersion: latest });
|
||||
|
||||
if (latest && isNewerVersion(latest, currentVersion)) {
|
||||
pendingNotification = latest;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { ExitCode, GLOBAL_OPTIONS } from "bailian-cli-core";
|
||||
import { parseFlags } from "../src/args.ts";
|
||||
import { BOOL_FLAG_WATERMARK } from "../src/utils/flag-descriptions.ts";
|
||||
|
||||
const IMAGE_GENERATE_OPTIONS = [
|
||||
{ flag: "--prompt <text>", description: "Image description", required: true },
|
||||
{ flag: "--model <model>", description: "Model ID" },
|
||||
{ flag: "--watermark <bool>", description: BOOL_FLAG_WATERMARK },
|
||||
{ flag: "--no-wait", description: "Return task ID immediately without waiting" },
|
||||
];
|
||||
|
||||
test("parseFlags rejects unknown long flags", () => {
|
||||
expect(() =>
|
||||
parseFlags(["--prompt", "cat", "--xxxx", "a"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
name: "BailianError",
|
||||
exitCode: ExitCode.USAGE,
|
||||
message: expect.stringContaining('Unknown flag "--xxxx"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseFlags rejects unknown flags with = syntax", () => {
|
||||
expect(() =>
|
||||
parseFlags(
|
||||
["--prompt=cat", "--unknown-flag=yes"],
|
||||
[...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS],
|
||||
),
|
||||
).toThrow(/Unknown flag "--unknown-flag"/);
|
||||
});
|
||||
|
||||
test("parseFlags accepts defined command and global flags", () => {
|
||||
const flags = parseFlags(
|
||||
["--quiet", "--prompt", "cat", "--watermark", "false"],
|
||||
[...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS],
|
||||
);
|
||||
expect(flags.quiet).toBe(true);
|
||||
expect(flags.prompt).toBe("cat");
|
||||
expect(flags.watermark).toBe("false");
|
||||
});
|
||||
|
||||
test("parseFlags rejects value flag when next token is another flag", () => {
|
||||
const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS];
|
||||
for (const argv of [
|
||||
["--watermark", "--prompt", "cat"],
|
||||
["--watermark", "-h"],
|
||||
["--prompt", "cat", "--watermark", "--model", "qwen-image-2.0"],
|
||||
]) {
|
||||
expect(() => parseFlags(argv, opts)).toThrowError(
|
||||
expect.objectContaining({
|
||||
name: "BailianError",
|
||||
exitCode: ExitCode.USAGE,
|
||||
message: expect.stringContaining("Flag --watermark requires a value"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseFlags rejects trailing value flag without value", () => {
|
||||
expect(() =>
|
||||
parseFlags(["--prompt", "cat", "--watermark"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Flag --watermark requires a value"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseFlags allows boolean flags without values adjacent to other flags", () => {
|
||||
const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS];
|
||||
const flags = parseFlags(
|
||||
["--quiet", "--dry-run", "--no-wait", "--prompt", "cat", "--watermark", "false"],
|
||||
opts,
|
||||
);
|
||||
expect(flags.quiet).toBe(true);
|
||||
expect(flags.dryRun).toBe(true);
|
||||
expect(flags.noWait).toBe(true);
|
||||
expect(flags.prompt).toBe("cat");
|
||||
expect(flags.watermark).toBe("false");
|
||||
});
|
||||
|
||||
test("parseFlags does not treat the next flag as a boolean flag value", () => {
|
||||
const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS];
|
||||
expect(() => parseFlags(["--dry-run", "--prompt"], opts)).toThrowError(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("Flag --prompt requires a value"),
|
||||
}),
|
||||
);
|
||||
// --dry-run is boolean: no value check; parsing continues to --prompt.
|
||||
const flags = parseFlags(["--dry-run", "--prompt", "cat"], opts);
|
||||
expect(flags.dryRun).toBe(true);
|
||||
expect(flags.prompt).toBe("cat");
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: advisor recommend", () => {
|
||||
test("advisor 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["advisor"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/advisor|recommend/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["advisor", "recommend", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/recommend|--message|dry-run/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isDashScopeE2EReady())("e2e: advisor recommend(DashScope)", () => {
|
||||
test("advisor recommend 缺少 --message 时打印帮助并退出 (0)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--message|Usage:/i);
|
||||
});
|
||||
|
||||
test("advisor recommend --dry-run 输出意图分析和候选列表", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"我想做一个能理解图片的客服机器人",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
userInput?: string;
|
||||
intent?: { requiredCapabilities?: string[]; inputModality?: string[] };
|
||||
candidateCount?: number;
|
||||
candidates?: Array<{ model?: string; score?: number }>;
|
||||
}>(stdout);
|
||||
expect(data.userInput).toBe("我想做一个能理解图片的客服机器人");
|
||||
expect(data.intent?.requiredCapabilities).toContain("VU");
|
||||
expect(data.intent?.inputModality).toContain("Image");
|
||||
expect(data.candidateCount).toBeGreaterThan(0);
|
||||
expect(data.candidates?.[0]?.model).toBeDefined();
|
||||
expect(data.candidates?.[0]?.score).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
test("advisor recommend 完整推荐流程返回结果", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--message",
|
||||
"低成本高并发的在线客服",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
type?: string;
|
||||
recommendations?: Array<{
|
||||
model?: string;
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
}>(stdout);
|
||||
expect(data.type).toBe("single");
|
||||
expect(data.recommendations?.length).toBeGreaterThan(0);
|
||||
expect(data.recommendations?.[0]?.model).toBeDefined();
|
||||
expect(data.recommendations?.[0]?.reason).toBeDefined();
|
||||
}, 120_000);
|
||||
|
||||
// ---- 模型偏好:正例 ----
|
||||
|
||||
test("scoped 偏好 — 限定系列时 intent 含 modelPreference.mode=scoped", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"deepseek系列中哪个模型最适合用来进行快速推理",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
expect(data.intent?.modelPreference?.mode).toBe("scoped");
|
||||
expect(data.intent?.modelPreference?.targets?.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
data.intent?.modelPreference?.targets?.some((target) =>
|
||||
target.toLowerCase().includes("deepseek"),
|
||||
),
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("comparison 偏好 — 对比模型时 intent 含 modelPreference.mode=comparison", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"qwen-max和deepseek-v3哪个更适合做代码生成",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string; targets?: string[] } };
|
||||
}>(stdout);
|
||||
expect(data.intent?.modelPreference?.mode).toBe("comparison");
|
||||
expect(data.intent?.modelPreference?.targets?.length).toBeGreaterThanOrEqual(2);
|
||||
}, 60_000);
|
||||
|
||||
test("excludes 偏好 — 排除模型时 intent 识别出 modelPreference", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"不要qwen,推荐一个适合文本生成的模型",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: {
|
||||
modelPreference?: { mode?: string; excludes?: string[]; targets?: string[] };
|
||||
};
|
||||
}>(stdout);
|
||||
const pref = data.intent?.modelPreference;
|
||||
expect(pref).toBeDefined();
|
||||
const hasExcludes =
|
||||
(pref?.excludes?.length ?? 0) > 0 ||
|
||||
(pref?.mode !== "unconstrained" && pref?.mode !== undefined);
|
||||
expect(hasExcludes).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
// ---- 模型偏好:反例 ----
|
||||
|
||||
test("无偏好 — 普通需求查询时 intent 不含 modelPreference 或 mode=unconstrained", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"advisor",
|
||||
"recommend",
|
||||
"--dry-run",
|
||||
"--message",
|
||||
"我要做一个能理解图片的客服机器人",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
intent?: { modelPreference?: { mode?: string } };
|
||||
}>(stdout);
|
||||
const mode = data.intent?.modelPreference?.mode;
|
||||
expect(mode === undefined || mode === "unconstrained").toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isDashScopeE2EReady, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。
|
||||
*/
|
||||
|
||||
describe("e2e: auth", () => {
|
||||
test("auth 分组展示子命令帮助且退出码为 0", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/auth|Authentication|login|logout|status/i);
|
||||
});
|
||||
|
||||
test("auth login --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/login|api-key/i);
|
||||
});
|
||||
|
||||
test("auth logout --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "logout", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/logout|dry-run|yes/i);
|
||||
});
|
||||
|
||||
test("auth status --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "status", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/status|output/i);
|
||||
});
|
||||
|
||||
test("auth login 缺少 --api-key 时打印子命令帮助并退出 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "login", "--non-interactive"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--api-key|Usage:/i);
|
||||
});
|
||||
|
||||
test("auth login --dry-run --api-key 不发起校验与落盘", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
"120",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login 缺少密钥且 --output json 时仍打印子命令帮助 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"login",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toMatch(/--api-key|Usage:/i);
|
||||
});
|
||||
|
||||
test("auth logout --dry-run 不写入配置", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["auth", "logout", "--dry-run"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --yes --non-interactive", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --quiet --no-color", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
"--quiet",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
});
|
||||
|
||||
test("auth logout --dry-run --output json(不清除密钥)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"logout",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("No changes made.");
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"status",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"text",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(
|
||||
/Authentication Status|API key:|Console token:|DashScope API:|Console gateway:/,
|
||||
);
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"status",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
api_key?: { configured?: boolean };
|
||||
dashscope_commands?: { method?: string };
|
||||
}>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.api_key?.configured).toBe(true);
|
||||
expect(data.dashscope_commands?.method).toBeDefined();
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status --output json --quiet --region cn", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"auth",
|
||||
"status",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--quiet",
|
||||
"--region",
|
||||
"cn",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ authenticated?: boolean; dashscope_commands?: unknown }>(stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.dashscope_commands).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures", "command-pack");
|
||||
let configDir: string;
|
||||
|
||||
function env(): NodeJS.ProcessEnv {
|
||||
return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" };
|
||||
}
|
||||
|
||||
describe("e2e: Command Pack", () => {
|
||||
beforeAll(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), "bl-command-pack-e2e-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("plugin 分组和管理命令 help 正常", async () => {
|
||||
const group = await runCli(["plugin"], env());
|
||||
expect(group.exitCode, group.stderr).toBe(0);
|
||||
expect(group.stderr).toContain("plugin install");
|
||||
expect(group.stderr).toContain("plugin link");
|
||||
expect(group.stderr).toContain("plugin list");
|
||||
expect(group.stderr).toContain("plugin remove");
|
||||
|
||||
const install = await runCli(["plugin", "install", "--help"], env());
|
||||
expect(install.exitCode, install.stderr).toBe(0);
|
||||
expect(install.stderr).toContain("--package");
|
||||
});
|
||||
|
||||
test("未 link 时插件命令不存在", async () => {
|
||||
const result = await runCli(["agent", "ping", "--message", "before"], env());
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/Unknown command/i);
|
||||
});
|
||||
|
||||
test("link 后命令进入原生 registry/help/执行链路", async () => {
|
||||
const linked = await runCli(
|
||||
["plugin", "link", "--path", fixtureRoot, "--output", "json"],
|
||||
env(),
|
||||
);
|
||||
expect(linked.exitCode, linked.stderr).toBe(0);
|
||||
const linkedJson = parseStdoutJson<{ linked: { name: string; commands: string[] } }>(
|
||||
linked.stdout,
|
||||
);
|
||||
expect(linkedJson.linked.name).toBe("@ali/bailian-plugin-agent");
|
||||
expect(linkedJson.linked.commands).toEqual([
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
]);
|
||||
|
||||
const rootHelp = await runCli(["--help"], env());
|
||||
expect(rootHelp.exitCode, rootHelp.stderr).toBe(0);
|
||||
expect(rootHelp.stderr).toContain("agent ping");
|
||||
|
||||
const commandHelp = await runCli(["agent", "ping", "--help"], env());
|
||||
expect(commandHelp.exitCode, commandHelp.stderr).toBe(0);
|
||||
expect(commandHelp.stderr).toContain("Ping the Command Pack fixture");
|
||||
expect(commandHelp.stderr).toContain("--message");
|
||||
|
||||
const executed = await runCli(["agent", "ping", "--message", "hello"], env());
|
||||
expect(executed.exitCode, executed.stderr).toBe(0);
|
||||
expect(executed.stdout).toContain("command-pack:hello");
|
||||
|
||||
const credential = await runCli(["agent", "credential", "--api-key", "fixture-key"], env());
|
||||
expect(credential.exitCode, credential.stderr).toBe(0);
|
||||
expect(credential.stdout).toContain("credential-source:flag");
|
||||
|
||||
const denied = await runCli(["agent", "credential-denied"], {
|
||||
...env(),
|
||||
DASHSCOPE_API_KEY: "fixture-key",
|
||||
});
|
||||
expect(denied.exitCode).toBe(1);
|
||||
expect(denied.stderr).toContain('must declare auth="apiKey"');
|
||||
|
||||
const outputText = await runCli(["agent", "output"], env());
|
||||
expect(outputText.exitCode, outputText.stderr).toBe(0);
|
||||
expect(outputText.stdout).toBe("command-pack-output\n");
|
||||
|
||||
const outputJson = await runCli(["agent", "output", "--output", "json"], env());
|
||||
expect(outputJson.exitCode, outputJson.stderr).toBe(0);
|
||||
expect(parseStdoutJson(outputJson.stdout)).toEqual({ source: "command-pack", ok: true });
|
||||
|
||||
const failed = await runCli(["agent", "fail", "--output", "text"], env());
|
||||
expect(failed.exitCode).toBe(2);
|
||||
expect(failed.stderr).toContain("Command Pack fixture usage error.");
|
||||
expect(failed.stderr).toContain("Use agent fail only in tests.");
|
||||
});
|
||||
|
||||
test("plugin list 输出加载状态", async () => {
|
||||
const result = await runCli(["plugin", "list", "--output", "json"], env());
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const json = parseStdoutJson<{
|
||||
command_packs: Array<{ name: string; status: string; commands: string[] }>;
|
||||
}>(result.stdout);
|
||||
expect(json.command_packs).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "@ali/bailian-plugin-agent",
|
||||
status: "loaded",
|
||||
commands: [
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("remove 后命令从下一进程消失", async () => {
|
||||
const removed = await runCli(
|
||||
["plugin", "remove", "--name", "@ali/bailian-plugin-agent", "--output", "json"],
|
||||
env(),
|
||||
);
|
||||
expect(removed.exitCode, removed.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ removed: string }>(removed.stdout).removed).toBe(
|
||||
"@ali/bailian-plugin-agent",
|
||||
);
|
||||
|
||||
const result = await runCli(["agent", "ping", "--message", "after"], env());
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/Unknown command/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
function withTempConfigDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
|
||||
return fn(dir).finally(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(dir: string, data: Record<string, unknown>): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: named config", () => {
|
||||
test("根帮助展示 --config 全局标志", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--config <name>/);
|
||||
});
|
||||
|
||||
test("config set --config 写入命名 block 且不影响默认配置", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, { output: "text", api_key: "sk-default" });
|
||||
|
||||
const setResult = await runCli(
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--config",
|
||||
"dev",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"json",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
const setData = parseStdoutJson<{
|
||||
output?: string;
|
||||
config?: string;
|
||||
config_file?: string;
|
||||
}>(setResult.stdout);
|
||||
expect(setData.output).toBe("json");
|
||||
expect(setData.config).toBe("dev");
|
||||
expect(setData.config_file).toBe(join(dir, "config.json"));
|
||||
|
||||
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.output).toBe("text");
|
||||
expect((raw.dev as Record<string, unknown>).output).toBe("json");
|
||||
});
|
||||
});
|
||||
|
||||
test("config show --config 只展示命名 block", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
output: "text",
|
||||
api_key: "sk-default",
|
||||
dev: { output: "json", access_token: "tok-dev" },
|
||||
});
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["config", "show", "--config", "dev", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Record<string, unknown>>(stdout);
|
||||
expect(data.config).toBe("dev");
|
||||
expect(data.config_file).toBe(join(dir, "config.json"));
|
||||
expect(data.output).toBe("json");
|
||||
expect(data.access_token).toBeDefined();
|
||||
expect(data.api_key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("auth status --config 不继承默认凭证", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } });
|
||||
|
||||
const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(devStatus.exitCode, devStatus.stderr).toBe(0);
|
||||
const devData = parseStdoutJson<Record<string, unknown>>(devStatus.stdout);
|
||||
expect(devData.authenticated).toBe(false);
|
||||
expect(devData.config).toBe("dev");
|
||||
|
||||
const defaultStatus = await runCli(["auth", "status", "--output", "json"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0);
|
||||
const defaultData = parseStdoutJson<Record<string, unknown>>(defaultStatus.stdout);
|
||||
expect(defaultData.authenticated).toBe(true);
|
||||
expect(defaultData.config).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
test("--config default 等价默认配置", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
active_config: "token-plan",
|
||||
output: "json",
|
||||
api_key: "sk-default",
|
||||
"token-plan": { output: "text", api_key: "sk-token" },
|
||||
});
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["config", "show", "--config", "default", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Record<string, unknown>>(stdout);
|
||||
expect(data.config).toBe("default");
|
||||
expect(data.active).toBeUndefined();
|
||||
expect(data.api_key).toBeDefined();
|
||||
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.active_config).toBe("token-plan");
|
||||
});
|
||||
});
|
||||
|
||||
test("非法 --config 名称报 usage error", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config name/);
|
||||
});
|
||||
|
||||
test("auth status 文本输出分行展示选中 Config 和配置文件", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
active_config: "token-plan",
|
||||
"token-plan": { api_key: "sk-token" },
|
||||
});
|
||||
|
||||
const result = await runCli(["auth", "status", "--output", "text"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Config: token-plan\n");
|
||||
expect(result.stdout).toContain(`Config file: ${join(dir, "config.json")}\n`);
|
||||
expect(result.stdout).not.toContain("Active config:");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Config 相关 E2E
|
||||
*/
|
||||
|
||||
describe("e2e: config", () => {
|
||||
test("config 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["config"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/config|show|set|export-schema/i);
|
||||
});
|
||||
|
||||
test("config show --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "show", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/show|config/i);
|
||||
});
|
||||
|
||||
test("config set --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "set", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config export-schema --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "export-schema", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/export-schema|--command/i);
|
||||
});
|
||||
|
||||
test("config show --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"show",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
region?: string;
|
||||
config_file?: string;
|
||||
base_url?: string;
|
||||
timeout?: number;
|
||||
}>(stdout);
|
||||
expect(data.region).toBeDefined();
|
||||
expect(data.config_file).toBeDefined();
|
||||
expect(data.base_url).toBeDefined();
|
||||
expect(data.timeout).toBeDefined();
|
||||
});
|
||||
|
||||
test("config show --output text --no-color", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"show",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"text",
|
||||
"--no-color",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toMatch(/region|config_file|timeout|base_url/i);
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["config", "set", "--non-interactive"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--key|--value|required/i);
|
||||
});
|
||||
|
||||
test("config set 非法 key 时退出为用法错误", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"not-a-real-key",
|
||||
"--value",
|
||||
"x",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config key|not-a-real-key/i);
|
||||
});
|
||||
|
||||
test("config set 非法 region", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"region",
|
||||
"--value",
|
||||
"invalid-region",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid region|cn, us, intl/i);
|
||||
});
|
||||
|
||||
test("config set 非法 output", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"yaml",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid output|text, json/i);
|
||||
});
|
||||
|
||||
test("config set 非法 timeout", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"timeout",
|
||||
"--value",
|
||||
"0",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid timeout|positive/i);
|
||||
});
|
||||
|
||||
test("config set --dry-run 不落盘(仅输出 would_set)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"json",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { output?: string } }>(stdout);
|
||||
expect(data.would_set?.output).toBe("json");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持连字符别名 key", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--non-interactive",
|
||||
"--key",
|
||||
"default-text-model",
|
||||
"--value",
|
||||
"qwen3.7-max",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
test("config export-schema --command 导出单条工具 JSON", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"export-schema",
|
||||
"--command",
|
||||
"text chat",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const schema = parseStdoutJson<{ name?: string; input_schema?: { type?: string } }>(stdout);
|
||||
expect(schema.name).toMatch(/bailian_text_chat/);
|
||||
expect(schema.input_schema?.type).toBe("object");
|
||||
});
|
||||
|
||||
test("config export-schema 不存在的子命令时报错", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"export-schema",
|
||||
"--command",
|
||||
"this-command-does-not-exist-xyz",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
const err = JSON.parse(stderr.trim()) as { error?: { message?: string } };
|
||||
expect(err.error?.message).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
test("config export-schema 导出全部为 JSON 数组", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"config",
|
||||
"export-schema",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const arr = parseStdoutJson<Array<{ name?: string }>>(stdout);
|
||||
expect(Array.isArray(arr)).toBe(true);
|
||||
expect(arr.length).toBeGreaterThan(0);
|
||||
expect(arr[0]?.name).toMatch(/^bailian_/);
|
||||
});
|
||||
});
|
||||
@@ -1,174 +1,52 @@
|
||||
import { execFile } from "child_process";
|
||||
import { mkdirSync, readFileSync } from "fs";
|
||||
import { promisify } from "util";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
import {
|
||||
cliTimeoutPrefix,
|
||||
cliTimeoutSeconds,
|
||||
e2eLabelFromMetaUrl,
|
||||
isConsoleAuthFailure,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
} from "e2e/output";
|
||||
import { runNodeMain, type RunCliResult } from "e2e/runner";
|
||||
import {
|
||||
isBailianE2EEnabled,
|
||||
isBailianE2EMediaEnabled,
|
||||
isBailianE2EVideoEnabled,
|
||||
isChatE2EReady,
|
||||
isConsoleE2EReady,
|
||||
isDashScopeE2EReady,
|
||||
isSearchE2EReady,
|
||||
} from "e2e/gating";
|
||||
import { monorepoRoot } from "e2e/monorepo-root";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
export {
|
||||
cliTimeoutPrefix,
|
||||
cliTimeoutSeconds,
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EEnabled,
|
||||
isBailianE2EMediaEnabled,
|
||||
isBailianE2EVideoEnabled,
|
||||
isChatE2EReady,
|
||||
isConsoleAuthFailure,
|
||||
isConsoleE2EReady,
|
||||
isDashScopeE2EReady,
|
||||
isSearchE2EReady,
|
||||
makeE2eOutputDir,
|
||||
monorepoRoot,
|
||||
parseStdoutJson,
|
||||
};
|
||||
export type { RunCliResult };
|
||||
|
||||
/**
|
||||
* Vitest `global-setup.ts` 写入 `test/output/` 下本文件名,供各 worker 进程读取同一会话 id。
|
||||
* (仅模块内变量无法跨 Vitest 多进程 worker 共享。)
|
||||
*/
|
||||
export const E2E_RUN_SESSION_FILENAME = ".e2e-run-session";
|
||||
|
||||
/**
|
||||
* 单次 `vp test` / Vitest 运行共用的 E2E 输出会话目录名(惰性缓存于当前进程)。
|
||||
*/
|
||||
let e2eOutputSessionId: string | undefined;
|
||||
|
||||
/** `packages/cli` 根目录(含 `src/main.ts`) */
|
||||
/** `packages/cli` 根目录 */
|
||||
export const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
const mainTs = join(cliPackageRoot, "src", "main.ts");
|
||||
|
||||
/** Monorepo 根(含根 `package.json`) */
|
||||
export function monorepoRoot(): string {
|
||||
return join(cliPackageRoot, "..", "..");
|
||||
}
|
||||
|
||||
function readE2eRunSessionFromOutputDir(): string | undefined {
|
||||
try {
|
||||
const p = join(monorepoRoot(), "test", "output", E2E_RUN_SESSION_FILENAME);
|
||||
const t = readFileSync(p, "utf8").trim();
|
||||
return t.length > 0 ? t : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getE2eOutputSessionId(): string {
|
||||
if (!e2eOutputSessionId) {
|
||||
const fromEnv = process.env.BAILIAN_E2E_RUN_ID?.trim();
|
||||
if (fromEnv) {
|
||||
e2eOutputSessionId = fromEnv.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
} else {
|
||||
const fromFile = readE2eRunSessionFromOutputDir();
|
||||
if (fromFile) {
|
||||
e2eOutputSessionId = fromFile.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
} else {
|
||||
e2eOutputSessionId = `e2e-run-${Date.now()}-${process.pid}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return e2eOutputSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 `test/output/<会话>/` 下创建用例子目录。
|
||||
* 会话 id 优先 `BAILIAN_E2E_RUN_ID`,否则读 Vitest globalSetup 写入的 `test/output/.e2e-run-session`,
|
||||
* 再否则回退为单进程 id(非 Vitest 直接跑用例时)。
|
||||
* 若已设 `BAILIAN_E2E_OUT` 则直接使用(不再套会话目录)。
|
||||
*/
|
||||
export function makeE2eOutputDir(label: string): string {
|
||||
const fromEnv = process.env.BAILIAN_E2E_OUT?.trim();
|
||||
if (fromEnv) {
|
||||
mkdirSync(fromEnv, { recursive: true });
|
||||
return fromEnv;
|
||||
}
|
||||
const safe = label.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const sessionDir = join(monorepoRoot(), "test", "output", getE2eOutputSessionId());
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
const dir = join(sessionDir, `e2e-vp-${safe}-${Date.now()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** 全局 `--timeout` 秒数(视频等长任务) */
|
||||
export function cliTimeoutSeconds(): string {
|
||||
return process.env.BAILIAN_E2E_TIMEOUT_SEC?.trim() || "3600";
|
||||
}
|
||||
|
||||
export function cliTimeoutPrefix(): string[] {
|
||||
return ["--timeout", cliTimeoutSeconds()];
|
||||
}
|
||||
|
||||
/** 显式开启后才跑真实网络 E2E,避免默认 `vp test` 依赖密钥或打外网 */
|
||||
export function isBailianE2EEnabled(): boolean {
|
||||
return process.env.BAILIAN_E2E === "1";
|
||||
}
|
||||
|
||||
/** 可调 DashScope 的 API Key:环境变量优先,否则读 ~/.bailian/config.json */
|
||||
export function isDashScopeE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_API_KEY?.trim()) return true;
|
||||
try {
|
||||
const f = readConfigFile();
|
||||
return typeof f.api_key === "string" && f.api_key.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 在仅跑文本/记忆/知识库时跳过) */
|
||||
export function isBailianE2EMediaEnabled(): boolean {
|
||||
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
|
||||
return isBailianE2EEnabled();
|
||||
}
|
||||
|
||||
/** 文生视频 / 图生视频 / 参考视频 / 视频编辑(耗时长,默认关闭) */
|
||||
export function isBailianE2EVideoEnabled(): boolean {
|
||||
return isBailianE2EEnabled() && process.env.BAILIAN_E2E_VIDEO === "1";
|
||||
}
|
||||
|
||||
/** 从 `import.meta.url` 生成 OUT 子目录标签,避免并行用例目录冲突 */
|
||||
export function e2eLabelFromMetaUrl(metaUrl: string): string {
|
||||
return basename(fileURLToPath(metaUrl), ".ts").replace(/\.e2e\.test$/, "");
|
||||
}
|
||||
|
||||
/** 知识库用例:须显式索引 ID + AK/SK(workspace 可读 config / env,故不在此强制校验) */
|
||||
export function isKnowledgeE2EReady(): boolean {
|
||||
return (
|
||||
isBailianE2EEnabled() &&
|
||||
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID &&
|
||||
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET &&
|
||||
!!process.env.BAILIAN_E2E_INDEX_ID
|
||||
);
|
||||
}
|
||||
|
||||
export interface RunCliResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子进程执行 CLI(等价于在 `packages/cli` 下 `node src/main.ts ...`)。
|
||||
* request_id 等诊断信息在 stderr;`--output json` 时 JSON 在 stdout。
|
||||
*/
|
||||
/** 子进程执行 bl CLI */
|
||||
export async function runCli(
|
||||
args: string[],
|
||||
envOverrides: NodeJS.ProcessEnv = {},
|
||||
): Promise<RunCliResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("node", [mainTs, ...args], {
|
||||
cwd: cliPackageRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: "1",
|
||||
DO_NOT_TRACK: "1",
|
||||
...envOverrides,
|
||||
},
|
||||
});
|
||||
return { stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 };
|
||||
} catch (err: unknown) {
|
||||
const e = err as {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number;
|
||||
};
|
||||
return {
|
||||
stdout: e.stdout ?? "",
|
||||
stderr: e.stderr ?? "",
|
||||
exitCode: typeof e.code === "number" ? e.code : 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStdoutJson<T = unknown>(stdout: string): T {
|
||||
const t = stdout.trim();
|
||||
return JSON.parse(t) as T;
|
||||
return runNodeMain(mainTs, args, { cwd: cliPackageRoot, env: envOverrides });
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EMediaEnabled,
|
||||
isDashScopeE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCli,
|
||||
} from "./helpers.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Image edit E2E
|
||||
*/
|
||||
|
||||
describe("e2e: image edit", () => {
|
||||
test("image 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["image"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const out = `${stdout}\n${stderr}`;
|
||||
expect(out).toMatch(/image|generate|edit/i);
|
||||
});
|
||||
|
||||
test("image edit --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["image", "edit", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/edit|--image|--prompt/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => {
|
||||
test("image edit 缺少 --image 时打印子命令帮助并退出 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"image",
|
||||
"edit",
|
||||
"--prompt",
|
||||
"仅提示词",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toMatch(/--image|Usage:/i);
|
||||
});
|
||||
|
||||
test("image edit 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => {
|
||||
const testPng = join(__dirname, ".smoke-32.png");
|
||||
const { stderr, exitCode } = await runCli([
|
||||
"image",
|
||||
"edit",
|
||||
"--image",
|
||||
testPng,
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toMatch(/--prompt|Usage:/i);
|
||||
});
|
||||
|
||||
test("【qwen-image-2.0】图片编辑", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const gen = await runCli([
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--out-prefix",
|
||||
"e2e-gen",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(gen.exitCode, gen.stderr).toBe(0);
|
||||
const genData = parseStdoutJson<{ urls?: string[] }>(gen.stdout);
|
||||
const imagePath = genData.urls?.[0];
|
||||
|
||||
expect(imagePath).toBeTruthy();
|
||||
|
||||
const ed = await runCli([
|
||||
"image",
|
||||
"edit",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"--image",
|
||||
imagePath!,
|
||||
"--prompt",
|
||||
"把背景改成浅蓝色",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--out-prefix",
|
||||
"e2e-edit",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(ed.exitCode, ed.stderr).toBe(0);
|
||||
const edData = parseStdoutJson<{ saved?: string[] }>(ed.stdout);
|
||||
expect(edData.saved?.length ?? 0).toBeGreaterThan(0);
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
isBailianE2EEnabled,
|
||||
isKnowledgeE2EReady,
|
||||
monorepoRoot,
|
||||
parseStdoutJson,
|
||||
runCli,
|
||||
} from "./helpers.ts";
|
||||
|
||||
// 已开启 E2E 但 AK/SK、索引等未齐时提醒配置根目录 .env(否则本文件整组 describe 会被 skip)
|
||||
if (isBailianE2EEnabled() && !isKnowledgeE2EReady()) {
|
||||
const envFile = join(monorepoRoot(), ".env");
|
||||
console.warn(
|
||||
[
|
||||
"[e2e:knowledge] 知识库检索需要 RAM 的 AK/SK、索引 ID,以及工作空间 ID;当前未就绪,本组用例将被跳过。",
|
||||
`请在 monorepo 根目录的 .env 中配置(${envFile}):`,
|
||||
" ALIBABA_CLOUD_ACCESS_KEY_ID",
|
||||
" ALIBABA_CLOUD_ACCESS_KEY_SECRET",
|
||||
" BAILIAN_E2E_INDEX_ID",
|
||||
" BAILIAN_WORKSPACE_ID(也可执行: bl config set workspace_id <工作空间 id>)",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
interface KnowledgeRetrieveBody {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
Data?: { Nodes?: unknown[] };
|
||||
}
|
||||
|
||||
/** 知识库检索(需 AK/SK + workspace + 索引;未就绪则整组跳过) */
|
||||
describe.skipIf(!isKnowledgeE2EReady())("e2e: knowledge retrieve", () => {
|
||||
test("知识库检索", async () => {
|
||||
const indexId = process.env.BAILIAN_E2E_INDEX_ID!;
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"knowledge",
|
||||
"retrieve",
|
||||
"--index-id",
|
||||
indexId,
|
||||
"--query",
|
||||
"端到端检索测试",
|
||||
"--top-k",
|
||||
"3",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<KnowledgeRetrieveBody>(stdout);
|
||||
const ok = data.Success === true || data.Code === "Success";
|
||||
expect(ok).toBe(true);
|
||||
expect(Array.isArray(data.Data?.Nodes)).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { deriveGroupPaths } from "e2e/registry-smoke";
|
||||
import { commands } from "../../src/commands.ts";
|
||||
import { runCli } from "./helpers.ts";
|
||||
|
||||
const commandPaths = Object.keys(commands).sort();
|
||||
const groupPaths = deriveGroupPaths(commandPaths);
|
||||
|
||||
describe("e2e: bl registry smoke", () => {
|
||||
test("根帮助展示 bl 与全局 flag", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/\bbl\b/i);
|
||||
expect(stderr).toMatch(/--base-url/);
|
||||
expect(stderr).toMatch(/--console-region/);
|
||||
expect(stderr).toMatch(/--console-site/);
|
||||
expect(stderr).toMatch(/--console-switch-agent/);
|
||||
expect(stderr).not.toMatch(/^\s*--region\s/m);
|
||||
});
|
||||
|
||||
test("quota check --help:Flags 含 console 域鉴权 flag,Global Flags 全量列出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["quota", "check", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/Global Flags:/);
|
||||
expect(stderr).toMatch(/--console-region <region>/);
|
||||
expect(stderr).toMatch(/--model <model>/);
|
||||
expect(stderr).toMatch(/--period <minutes>/);
|
||||
expect(stderr).toMatch(/--output <format>/);
|
||||
expect(stderr).not.toMatch(/API region \(default: cn-beijing\)/);
|
||||
});
|
||||
|
||||
test.each(commandPaths)("已注册命令 %s --help 成功", async (path) => {
|
||||
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test.each(groupPaths)("命令分组 %s --help 成功", async (path) => {
|
||||
const { stderr, exitCode } = await runCli([...path.split(" "), "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: usage summary", () => {
|
||||
test("usage summary --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["usage", "summary", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--days|summary|usage/i);
|
||||
});
|
||||
|
||||
test("usage summary --help 包含所有示例", async () => {
|
||||
const { stderr, exitCode } = await runCli(["usage", "summary", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("bl usage summary");
|
||||
expect(stderr).toContain("bl usage summary --days 30");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: usage summary(Console)", () => {
|
||||
test("usage summary --dry-run 输出 free-tier 计划请求", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"summary",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ freeTier?: { api?: string }; usage?: unknown }>(stdout);
|
||||
expect(data.freeTier?.api).toContain("queryFreeTierQuota");
|
||||
});
|
||||
|
||||
test("usage summary --dry-run --workspace-id 附带用量概览计划请求", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"usage",
|
||||
"summary",
|
||||
"--dry-run",
|
||||
"--workspace-id",
|
||||
"ws-e2e-dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
usage?: { api?: string; data?: { reqDTO?: { filterWorkspaceId?: string } } };
|
||||
}>(stdout);
|
||||
expect(data.usage?.api).toContain("getModelUsageStatistic");
|
||||
expect(data.usage?.data?.reqDTO?.filterWorkspaceId).toBe("ws-e2e-dry-run");
|
||||
});
|
||||
|
||||
test("usage summary 文本输出正常返回", async () => {
|
||||
const result = await runCli(["usage", "summary", "--output", "text"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("usage summary JSON 输出包含 freeTier 字段", async () => {
|
||||
const result = await runCli(["usage", "summary", "--output", "json"]);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ freeTier?: unknown; period?: unknown }>(result.stdout);
|
||||
expect(data.period).toBeTypeOf("object");
|
||||
expect(Array.isArray(data.freeTier)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
cliTimeoutPrefix,
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EVideoEnabled,
|
||||
isDashScopeE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCli,
|
||||
} from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Video generate (i2v):help / 分组不依赖密钥;长任务需视频 E2E + DashScope。
|
||||
*/
|
||||
|
||||
describe("e2e: video generate (i2v)", () => {
|
||||
test("video 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["video"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
|
||||
});
|
||||
|
||||
test("video generate --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["video", "generate", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/generate|--prompt|--image|model/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"e2e: video generate (i2v)(DashScope 视频)",
|
||||
() => {
|
||||
test("video generate 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-i2v",
|
||||
"--image",
|
||||
"https://example.com/placeholder.png",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toMatch(/--prompt|Usage:/i);
|
||||
});
|
||||
|
||||
test("video generate --dry-run(无 --image)仅输出 request(t2v 路径不调上传)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"generate",
|
||||
"--dry-run",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"--prompt",
|
||||
"干跑无图",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: { input?: { prompt?: string; media?: unknown } } }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.request?.input?.prompt).toBe("干跑无图");
|
||||
expect(data.request?.input?.media).toBeUndefined();
|
||||
});
|
||||
|
||||
test("【happyhorse-1.0-i2v】图片生成视频", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const png = join(outDir, "e2e-gen.png");
|
||||
const gen = await runCli([
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--out-prefix",
|
||||
"e2e-gen",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(gen.exitCode, gen.stderr).toBe(0);
|
||||
const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout);
|
||||
const imagePath = genData.saved?.[0] ?? png;
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-i2v",
|
||||
"--image",
|
||||
imagePath,
|
||||
"--prompt",
|
||||
"镜头缓慢推进,小猫微微动一下",
|
||||
"--download",
|
||||
join(outDir, "e2e-video-i2v.mp4"),
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ status?: string; video_url?: string; saved?: string }>(stdout);
|
||||
expect(data.status).toBe("SUCCEEDED");
|
||||
expect(data.video_url?.startsWith("https://")).toBe(true);
|
||||
}, 3_600_000);
|
||||
},
|
||||
);
|
||||
@@ -1,108 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
cliTimeoutPrefix,
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EVideoEnabled,
|
||||
isDashScopeE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCli,
|
||||
} from "./helpers.ts";
|
||||
|
||||
/**
|
||||
* Video ref (r2v):help / 分组不依赖密钥;参考生成需视频 E2E + DashScope。
|
||||
*/
|
||||
|
||||
describe("e2e: video ref (r2v)", () => {
|
||||
test("video 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["video"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/video|generate|edit|ref|task|download/i);
|
||||
});
|
||||
|
||||
test("video ref --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["video", "ref", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/ref|--prompt|--image|model/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"e2e: video ref (r2v)(DashScope 视频)",
|
||||
() => {
|
||||
test("video ref 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"ref",
|
||||
"--model",
|
||||
"happyhorse-1.0-r2v",
|
||||
"--image",
|
||||
"https://example.com/x.png",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stderr).toMatch(/--prompt|Usage:/i);
|
||||
});
|
||||
|
||||
test("video ref 缺少 --image 与 --ref-video 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"ref",
|
||||
"--model",
|
||||
"happyhorse-1.0-r2v",
|
||||
"--prompt",
|
||||
"仅有描述无素材",
|
||||
"--non-interactive",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--image|ref-video|At least one|required/i);
|
||||
});
|
||||
|
||||
test("【happyhorse-1.0-r2v】视频参考生成", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const gen = await runCli([
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
outDir,
|
||||
"--out-prefix",
|
||||
"e2e-gen",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(gen.exitCode, gen.stderr).toBe(0);
|
||||
const genData = parseStdoutJson<{ saved?: string[] }>(gen.stdout);
|
||||
const imagePath = genData.saved?.[0];
|
||||
expect(imagePath).toBeTruthy();
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
...cliTimeoutPrefix(),
|
||||
"video",
|
||||
"ref",
|
||||
"--model",
|
||||
"happyhorse-1.0-r2v",
|
||||
"--prompt",
|
||||
"图1在画面中心轻微晃动",
|
||||
"--image",
|
||||
imagePath!,
|
||||
"--download",
|
||||
join(outDir, "e2e-video-r2v.mp4"),
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ status?: string; video_url?: string }>(stdout);
|
||||
expect(data.status).toBe("SUCCEEDED");
|
||||
expect(data.video_url?.startsWith("https://")).toBe(true);
|
||||
}, 3_600_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
const ping = {
|
||||
description: "Ping the Command Pack fixture",
|
||||
auth: "none",
|
||||
flags: {
|
||||
message: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
required: true,
|
||||
description: "Message returned by the fixture",
|
||||
},
|
||||
},
|
||||
usageArgs: "--message <text>",
|
||||
exampleArgs: ['--message "hello"'],
|
||||
async run(ctx) {
|
||||
process.stdout.write(`command-pack:${ctx.flags.message}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
const credential = {
|
||||
description: "Read an API key through the Command Pack host adapter",
|
||||
auth: "apiKey",
|
||||
async run(ctx) {
|
||||
const apiKey = ctx.credentials.apiKey();
|
||||
process.stdout.write(`credential-source:${apiKey.source}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
const credentialDenied = {
|
||||
description: "Verify credential access also requires command auth",
|
||||
auth: "none",
|
||||
async run(ctx) {
|
||||
ctx.credentials.apiKey();
|
||||
},
|
||||
};
|
||||
|
||||
const output = {
|
||||
description: "Exercise the Command Pack output helper",
|
||||
auth: "none",
|
||||
async run(ctx) {
|
||||
ctx.output.result({ source: "command-pack", ok: true }, { text: "command-pack-output" });
|
||||
},
|
||||
};
|
||||
|
||||
const fail = {
|
||||
description: "Exercise the Command Pack semantic error helper",
|
||||
auth: "none",
|
||||
async run(ctx) {
|
||||
throw ctx.errors.usage("Command Pack fixture usage error.", "Use agent fail only in tests.");
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
"agent credential": credential,
|
||||
"agent credential-denied": credentialDenied,
|
||||
"agent fail": fail,
|
||||
"agent output": output,
|
||||
"agent ping": ping,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@ali/bailian-plugin-agent",
|
||||
"version": "0.0.0-test",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bailianCli": {
|
||||
"type": "command-pack",
|
||||
"apiVersion": 1,
|
||||
"entry": "./commands.mjs",
|
||||
"minCliVersion": "1.7.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 子进程执行 CLI:spawn node main.ts,解析 stdout。
|
||||
* 子进程执行 CLI:spawn 仓库本地 tsx main.ts,解析 stdout。
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveTsxBin } from "./paths.mjs";
|
||||
import { truncateLog, extractError, isRateLimitFailure } from "./parsers.mjs";
|
||||
import { captureTraceIdsFromText, enrichTraceIdsAsync } from "./trace-ids.mjs";
|
||||
|
||||
@@ -51,12 +52,13 @@ export function executeSingleCli(ctx) {
|
||||
parseStdout,
|
||||
readFileOptional,
|
||||
asrOutPath,
|
||||
TSX_BIN = resolveTsxBin(),
|
||||
} = ctx;
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("node", [MAIN_TS, ...stressCliArgs(cliArgs)], {
|
||||
const child = spawn(TSX_BIN, [MAIN_TS, ...stressCliArgs(cliArgs)], {
|
||||
cwd: CLI_PACKAGE,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { DEFAULT_CLI_PACKAGE, MONOREPO_ROOT, resolveMainTs } from "./paths.mjs";
|
||||
import { DEFAULT_CLI_PACKAGE, MONOREPO_ROOT, resolveMainTs, resolveTsxBin } from "./paths.mjs";
|
||||
import { parseStressArgv, optFrom } from "./argv-parse.mjs";
|
||||
import { resolveStressCountAndConcurrency } from "./stress-config.mjs";
|
||||
import { SubmissionRateLimiter } from "./rate-limit.mjs";
|
||||
@@ -38,6 +38,7 @@ export function defineStressTarget(config) {
|
||||
const globals = ctx?.globals ?? {};
|
||||
const CLI_PACKAGE = optFrom(ARGV, "CLI_PACKAGE") || DEFAULT_CLI_PACKAGE;
|
||||
const MAIN_TS = resolveMainTs(CLI_PACKAGE);
|
||||
const TSX_BIN = resolveTsxBin();
|
||||
|
||||
const canonical = ctx?.canonicalTarget ?? config.canonical;
|
||||
const {
|
||||
@@ -142,9 +143,9 @@ export function defineStressTarget(config) {
|
||||
return stressExit(ctx, 1);
|
||||
}
|
||||
try {
|
||||
await execFileAsync("node", ["--version"], { encoding: "utf8" });
|
||||
await execFileAsync(TSX_BIN, ["--version"], { encoding: "utf8" });
|
||||
} catch {
|
||||
console.error("未找到 node。");
|
||||
console.error("未找到仓库本地 tsx,请先运行 pnpm install。");
|
||||
return stressExit(ctx, 1);
|
||||
}
|
||||
|
||||
@@ -177,6 +178,7 @@ export function defineStressTarget(config) {
|
||||
return executeSingleCli({
|
||||
MAIN_TS,
|
||||
CLI_PACKAGE,
|
||||
TSX_BIN,
|
||||
TIMEOUT_MS,
|
||||
MAX_LOG_CAPTURE,
|
||||
index,
|
||||
|
||||
@@ -97,7 +97,6 @@ export async function ensurePrerequisites(ctx) {
|
||||
"压测前置语音样本,用于语音识别链路。",
|
||||
"--out",
|
||||
outAudio,
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
];
|
||||
@@ -137,7 +136,6 @@ export async function ensurePrerequisites(ctx) {
|
||||
fixturesDir,
|
||||
"--out-prefix",
|
||||
"stress-setup-image",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
@@ -180,14 +178,13 @@ export async function ensurePrerequisites(ctx) {
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"压测前置短视频:海浪与静态远景,无明显人物。",
|
||||
"--duration",
|
||||
"5",
|
||||
"--download",
|
||||
downloadPath,
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
|
||||
@@ -78,7 +78,7 @@ export function parseImageResult(stdout) {
|
||||
const ids = data.task_ids ?? [data.task_id];
|
||||
return {
|
||||
ok: false,
|
||||
error: `仅返回 task_id,未等待生成完成: ${ids.join(", ")}。请勿使用 --no-wait,或检查 ~/.bailian/config.json 是否开启 async`,
|
||||
error: `仅返回 task_id,未等待生成完成: ${ids.join(", ")}。请勿使用 --async,或检查调用参数是否开启 async`,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: "JSON 中无 urls / saved 字段(可能生成未完成)" };
|
||||
@@ -169,7 +169,7 @@ export function parseVideoResult(stdout) {
|
||||
const ids = taskIds ?? [taskId];
|
||||
return {
|
||||
ok: false,
|
||||
error: `仅返回 task_id,未等待生成完成: ${ids.join(", ")}。请勿使用 --no-wait,或检查 ~/.bailian/config.json 是否开启 async`,
|
||||
error: `仅返回 task_id,未等待生成完成: ${ids.join(", ")}。请勿使用 --async,或检查调用参数是否开启 async`,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: "JSON 中无 video_url / saved 字段(可能生成未完成)" };
|
||||
|
||||
@@ -16,7 +16,22 @@ export const DEFAULT_CLI_PACKAGE = join(STRESS_ROOT, "..", "..");
|
||||
/** monorepo 根目录 */
|
||||
export const MONOREPO_ROOT = join(DEFAULT_CLI_PACKAGE, "..", "..");
|
||||
|
||||
/** CLI 入口 main.ts(ts-node 或直接 node ts 由项目脚本决定) */
|
||||
/** CLI 入口 main.ts(由仓库本地 tsx 执行) */
|
||||
export function resolveMainTs(cliPackage = DEFAULT_CLI_PACKAGE) {
|
||||
return join(cliPackage, "src", "main.ts");
|
||||
}
|
||||
|
||||
/** monorepo 本地 bin 路径,避免 `pnpm run` 生命周期日志污染 stdout */
|
||||
export function resolveLocalBin(name) {
|
||||
return join(
|
||||
MONOREPO_ROOT,
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? `${name}.cmd` : name,
|
||||
);
|
||||
}
|
||||
|
||||
/** tsx 可执行文件路径 */
|
||||
export function resolveTsxBin() {
|
||||
return resolveLocalBin("tsx");
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
|
||||
"压测前置语音样本,用于语音识别链路。",
|
||||
"--out",
|
||||
outAudio,
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
@@ -95,7 +94,6 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
|
||||
fixturesDir,
|
||||
"--out-prefix",
|
||||
"stress-setup-image",
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
@@ -132,14 +130,13 @@ export async function generateCombinedFixtures({ suiteRoot, cliPackage }) {
|
||||
"video",
|
||||
"generate",
|
||||
"--model",
|
||||
"happyhorse-1.0-t2v",
|
||||
"happyhorse-1.1-t2v",
|
||||
"--prompt",
|
||||
"压测前置短视频:海浪与静态远景,无明显人物。",
|
||||
"--duration",
|
||||
"5",
|
||||
"--download",
|
||||
downloadPath,
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
|
||||
@@ -52,7 +52,6 @@ export const runStress = defineStressTarget({
|
||||
prompt,
|
||||
"--out-dir",
|
||||
runDir,
|
||||
"--non-interactive",
|
||||
"--output",
|
||||
"json",
|
||||
"--timeout",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user