Compare commits

..

84 Commits

Author SHA1 Message Date
rendianmeng 575110e62d chore: sync packages/cli README with root for publish
Publish check requires root and packages/cli README files to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:39:24 +08:00
rendianmeng 882bc6becb feat(asset-center): remove oss transfer commands 2026-08-07 14:50:56 +08:00
rendianmeng 78ca730100 feat: asset center oss api test 2026-08-06 15:46:31 +08:00
rendianmeng 539247b10d Merge branch 'main' into feat/asset-center
Keep asset-center and managed-agent command exports, regenerate skill-scoped references.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:59:22 +08:00
Gong Shiqi 6338df36be Merge pull request #138 from modelstudioai/feat/update-defmodel
Update default image model to qwen-image-3.0
2026-08-05 19:43:48 +08:00
若麒 cb6740965f chore(release): prepare 1.14.1 2026-08-05 19:35:05 +08:00
Gong Shiqi 2dffee5b7a Merge pull request #139 from modelstudioai/feat/source-config-tags
feat: add CLI source config tags
2026-08-05 17:44:40 +08:00
若麒 01ec13aad8 feat: add CLI source config tags 2026-08-05 17:37:14 +08:00
clh02467605 b68ff45fb9 Merge remote-tracking branch 'refs/remotes/origin/main' into feat/update-defmodel 2026-08-05 17:08:44 +08:00
clh02467605 4990b27436 feat: update image default model 2026-08-05 16:58:29 +08:00
gujieye 262681484b Merge pull request #137 from modelstudioai/feat/deploy-update
feat: align agent registry with upstream and harden cross-platform install
2026-08-05 16:21:10 +08:00
故璃 8488b251f7 Merge branch 'main' into feat/deploy-update 2026-08-05 16:11:38 +08:00
Gong Shiqi b1908fa879 Merge pull request #134 from modelstudioai/chore/opti-skill
refactor(skills): split domain skills and introduce bailian-protocol companion
2026-08-05 11:16:08 +08:00
clh02467605 d64ba09bef merge: merged main to current branch 2026-08-05 10:59:26 +08:00
clh02467605 8cdd54cf7a docs(skills): remove companions claim; make --all -g the supported install path 2026-08-05 10:27:39 +08:00
故璃 121fa1317f feat(skills): align agent registry with upstream and harden cross-platform install 2026-08-05 10:17:06 +08:00
Gong Shiqi 564e21d9f1 Merge pull request #130 from modelstudioai/feat/multi-channel-install
Feat/multi channel install
2026-08-04 20:30:54 +08:00
若麒 081d09863b Merge branch 'main' into feat/multi-channel-install 2026-08-04 20:22:26 +08:00
clh02467605 17b13de162 merge: merged main to current branch 2026-08-04 18:43:50 +08:00
clh02467605 ca98d8a25d refactor(skills): introduce bailian-protocol companion and slim bailian-cli routing 2026-08-04 18:16:30 +08:00
若麒 1e1f5306b3 chore(release): prepare 1.14.0 2026-08-04 18:11:47 +08:00
clh02467605 13158856e8 feat: Refactor skills by granularity and optimize constraints 2026-08-04 15:31:07 +08:00
gujieye cf2592c07d Merge pull request #133 from modelstudioai/feat/bailian-wiki-doc-sync
feat: add skill commend & wiki sync
2026-08-03 20:07:35 +08:00
故璃 3766b6d7ca Merge branch 'main' into feat/bailian-wiki-doc-sync 2026-08-03 19:33:16 +08:00
故璃 1962758b0c feat: add request id 2026-08-03 19:32:27 +08:00
若麒 026e250cd3 Merge branch 'main' into feat/multi-channel-install 2026-08-03 17:16:56 +08:00
Gong Shiqi 6d61afc1d5 Merge pull request #132 from modelstudioai/feat/update-defmodel
feat: switch default text model to qwen3.8-max
2026-08-03 16:25:17 +08:00
若麒 7a870ec417 chore(release): prepare 1.13.1 2026-08-03 16:19:00 +08:00
clh02467605 1c38c381e5 feat: switch default text model to qwen3.8-max
Align text chat, pipeline, config UI, login validation, and Token Plan
text presets, and update README, skill reference, and related tests.
2026-08-03 15:34:18 +08:00
rendianmeng 658763af2c fix: ci test 2026-08-03 15:29:55 +08:00
rendianmeng da2ddb7a55 fix: ci test 2026-08-03 14:42:31 +08:00
rendianmeng be3033baf9 feat: win bl update exe file test 2026-07-31 19:40:53 +08:00
rendianmeng 8ad3e7b947 feat: win bl update exe file test 2026-07-31 19:05:53 +08:00
rendianmeng 525412f566 feat: win bl update exe file test 2026-07-31 18:53:37 +08:00
rendianmeng 75b056ba64 feat: win bl update exe file test 2026-07-31 18:16:53 +08:00
rendianmeng f5a36b1787 feat: win bl update exe file test 2026-07-31 17:57:28 +08:00
rendianmeng 9fb388b75d Merge branch 'main' of github.com:modelstudioai/cli into feat/multi-channel-install 2026-07-31 17:47:10 +08:00
rendianmeng 45d468838f feat: win bl update exe file test 2026-07-31 17:44:59 +08:00
ls ed81178ad7 Merge pull request #118 from modelstudioai/feat/config-ui-enhancements
Feat/config UI enhancements (本地配置管理面板能力增强)
2026-07-31 00:30:32 +08:00
lisheng.lisheng 7e23ba00fb chore(release): 发布 v1.13.0 版本
- 增加 `bl config ui` 功能,支持技能、MCP、代理和资产清单浏览与管理
- 新增模型目录建议芯片,方便配置 UI 中快速填充模型名
- 实现配置文件的 Profile 磁贴网格展示及新增弹窗
- 优化配置 UI 布局,增强响应式布局和编辑体验
- 修复软链接技能目录识别问题
- 支持基于环境变量的配置文件路径及旧版配置方案
- 同步更新相关包版本至 1.13.0
2026-07-31 00:21:51 +08:00
clh02467605 72955d66a7 refactor(skill): update bailian-cli metadata sync to handle multiple skills
Enhanced the sync script to update the `metadata.version` for all skills in the `skills` directory, rather than just `bailian-cli`. Improved error handling for missing frontmatter and ensured proper versioning across all skill files.
2026-07-30 15:50:29 +08:00
rendianmeng 389c932390 test(runtime): expect npm --version probe in command pack install
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:38:40 +08:00
rendianmeng 6870dc50a6 style: fix AGENTS.md table formatting for vp check
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:32:39 +08:00
rendianmeng 54b95ed122 Merge branch main of github.com:modelstudioai/cli into feat/multi-channel-install 2026-07-30 10:20:53 +08:00
rendianmeng 5e2833569a Merge branch main of github.com:modelstudioai/cli into feat/multi-channel-install 2026-07-30 10:12:42 +08:00
rendianmeng 434aac5b08 docs: install shell md 2026-07-30 10:05:31 +08:00
故璃 e46053b93e fix(tooling): stop interpolating filenames into staged check
Passing staged filenames per-file puts repository paths into the argv of
the vp check node process. When an endpoint security agent matches process
argv by substring, the whole process is SIGKILLed and pre-commit can never
finish. Use the function form so the command runs without filenames: one
whole-repo check, wider coverage than per-file, and independent of any path.
2026-07-29 17:54:54 +08:00
故璃 30fe8182f4 Merge branch 'main' into feat/bailian-wiki-doc-sync
# Conflicts:
#	packages/cli/src/commands.ts
#	packages/commands/tests/e2e/topic-routes.ts
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
#	skills/bailian-cli/reference/index.md
2026-07-29 17:34:28 +08:00
故璃 65c0fe9604 feat: add skill commend & skill install 2026-07-29 17:04:17 +08:00
lisheng.lisheng 2c53b0692b refactor(inventory): 优化技能与代理配置代码格式和检测逻辑
- 统一代码格式,增加多处代码块的换行和缩进保持一致
- 调整技能安装目标列表的格式,提升可读性
- 修复解压缩逻辑中异常抛出格式,增强异常信息规范
- 优化归一化文件名过滤条件表达式格式
- 修改配置文件检测逻辑,兼容环境变量和旧版配置方案
- 增强对 Bailian 相关模型提供者的检测逻辑支持
- 规范代理详情字段生成方法的代码风格
- 调整 MCP 写回相关函数的格式,提升可维护性
- 改进技能和代理详情函数参数格式,统一参数拆分显示
- 修复单元测试中路径和 JSON 写入格式,增加不同配置场景测试覆盖
- 确保软链接技能目录被正确识别为安装来源
- 增加多代理配置文件和技能安装的检测测试用例,提升测试精准度
2026-07-28 20:51:07 +08:00
lisheng.lisheng adc89f635d Merge branch 'main' of github.com:modelstudioai/cli into feat/config-ui-enhancements
# Conflicts:
#	packages/commands/tests/config-ui.test.ts
2026-07-28 20:43:42 +08:00
rendianmeng fb0c4b81be docs: install shell md 2026-07-28 14:08:32 +08:00
rendianmeng 952f2277a4 docs: install shell md 2026-07-28 13:51:22 +08:00
rendianmeng 871c667e97 docs: install shell md 2026-07-28 13:49:51 +08:00
clh02467605 4c494207d6 docs(skill): prefer bailian-cli for image/video/audio generation routing
Lead the skill description with a dedicated media-generation entry and
stronger class-3 priority so agents pick bl for gen/edit tasks, while
keeping host-first routing for ordinary text/search.
2026-07-28 10:25:54 +08:00
rendianmeng af3286dd00 Merge branch 'feat/multi-channel-install' of github.com:modelstudioai/cli into feat/multi-channel-install 2026-07-28 10:20:25 +08:00
rendianmeng 6465c4a78a feat: install shell test 2026-07-28 10:19:54 +08:00
故璃 467756b319 feat: update manifest.json 2026-07-28 10:17:16 +08:00
故璃 7250de9228 feat: add changelog sync to oss 2026-07-27 16:56:46 +08:00
故璃 51ed69596e feat: skill update REASON opt 2026-07-27 16:25:49 +08:00
故璃 67b7fa30a7 feat: opt bl skill update commend, keep it atom 2026-07-27 16:02:27 +08:00
故璃 bd17c27023 feat: index.json protocol adapter 2026-07-27 15:41:04 +08:00
故璃 87c37994f2 feat: update skill commend group 2026-07-27 12:30:20 +08:00
故璃 ebbd173b79 feat: update manifest.json path 2026-07-25 08:43:38 +08:00
故璃 6bdc16597b feat: add secret 2026-07-25 08:07:29 +08:00
故璃 e736bab9c1 feat: add installer sync 2026-07-25 00:33:11 +08:00
故璃 8dd786287f feat: add skill commend 2026-07-24 19:56:53 +08:00
rendianmeng d30fb2ae68 feat(release): distribute binaries as per-platform zips 2026-07-24 15:33:15 +08:00
rendianmeng a1a448c5d2 fix(release): fix binary CI publish and clarify release modules
Stabilize Bun compile on 1.2.19, align manifests with OSS consumers,
and split gh / webhook / mode helpers out of binary-release.
2026-07-24 10:35:46 +08:00
rendianmeng 7b949d3d3c fix(release): fix binary CI publish and clarify release modules
Stabilize Bun compile on 1.2.19, align manifests with OSS consumers,
and split gh / webhook / mode helpers out of binary-release.
2026-07-24 10:34:39 +08:00
rendianmeng 168e2b5ccb build: multi channel install test 2026-07-23 18:18:46 +08:00
rendianmeng 9fbd2e4ec6 build: multi channel install test 2026-07-23 18:12:42 +08:00
rendianmeng 4bd84e934c build: multi channel install test 2026-07-23 17:52:52 +08:00
rendianmeng 08bdc3be97 build: multi channel install test 2026-07-23 17:36:14 +08:00
rendianmeng 66a797203c multi channel install test 2026-07-23 17:34:30 +08:00
故璃 90a44d7140 feat: llm wiki sync 2026-07-23 15:31:57 +08:00
inhai e1caee99f2 feat(config-ui): MCP management, skill zip install, and UI polish
- MCP: editable JSON config in the detail drawer with secret masking and
  mask-preserving writes; create/update/delete across claude-code, qwen-code,
  opencode, cursor, windsurf, gemini, qoderwork, openclaw and Claude Desktop
- Skills: upload a .zip and install into any agent's skills root (self-contained
  ZIP reader, zip-slip safe); scan more roots (openclaw workspace, qoderwork,
  windsurf/codeium, gemini antigravity, workbuddy)
- Markdown: GFM table rendering in the skill detail drawer
- Layout: collapsible grouped sidebar with icons + persistent state, responsive
  breakpoint, wider main, single-line tile titles, 2-line description clamp,
  round icon run buttons, custom file picker, modal spacing
- Server: /api/mcp POST/DELETE, /api/skill/install, binary upload reader,
  constant-time token compare, CSP/no-store headers, error logging
2026-07-23 10:52:26 +08:00
rendianmeng 10ddd0a948 Merge branch 'main' into feat/asset-center
Resolve conflicts by keeping asset-center commands alongside main's plugin/workspace/config updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 13:33:15 +08:00
inhai 9ab5de8c2e feat(config-ui): enrich config UI with skills, MCP, agents, assets and model catalog
- Add Skills / MCP / Agents / Assets inventory views with click-to-open
  right-side detail drawers (reusable infoDrawer)
- Render SKILL.md as Markdown via a self-contained, XSS-safe inline renderer
  (HTML-escape first, strip YAML frontmatter, no external deps)
- Add local vs remote origin badges to Skills and MCP items
- Add quick-launch for coding agents (allowlisted id->binary, execFile, no
  shell); gate the button on Connected AND the CLI binary being on PATH
- Add per-category model catalog surfaced as click-to-fill suggestion chips
  under each default_*_model field, sourced from real bl pipeline model names
- Add assets browser (categorized, time-sorted) with preview, open-locally
  and delete, backed by path-traversal-guarded file serving
- Convert Profiles to a tile grid with an add-tile and design-consistent
  new-profile modal; make view headers sticky and use drawers for editing
- Tests for inventory, agent-launch, assets and config-ui endpoints
2026-07-21 21:54:27 +08:00
rendianmeng 7b5af2c205 feat: api testing 2026-07-20 09:53:34 +08:00
故璃 d08edf0cd8 feat: sync wiki data from oss by fc 2026-07-17 16:43:06 +08:00
rendianmeng 2a1c96fb43 feat: 接口调试中 2026-07-10 16:27:28 +08:00
rendianmeng 788d3faafc Merge branch main of github.com:modelstudioai/cli into feat/asset-center 2026-07-10 14:10:20 +08:00
rendianmeng f32bd2323f feat: asset-center init 2026-07-10 14:07:42 +08:00
215 changed files with 18678 additions and 1012 deletions
+52 -5
View File
@@ -18,7 +18,7 @@ on:
- channel
- stable
channel:
description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)"
description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli is npm-only."
required: false
type: string
@@ -29,11 +29,11 @@ concurrency:
jobs:
publish-stable:
if: inputs.mode == 'stable'
name: publish stable (${{ inputs.package }}) to npm + tag
name: publish stable (${{ inputs.package }}) to npm + binary + tag
runs-on: ubuntu-latest
environment: production # Required Reviewers gate
permissions:
contents: write # push lightweight tag to origin
contents: write # push tag + create GitHub Release with binary assets
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- uses: actions/checkout@v6
@@ -55,19 +55,47 @@ jobs:
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Ensure zip (per-platform binary archives)
run: sudo apt-get update && sudo apt-get install -y zip
- run: pnpm install --frozen-lockfile
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.19"
- name: publish-stable
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# OSS release channel runs fully in CI: upload + reconcile + manifest.json.
# All values come from repo Settings → Secrets — no OSS defaults live in
# code. Leave AK/SK unset to skip the OSS channel; once enabled,
# bucket/region/prefix are required.
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
publish-channel:
if: inputs.mode == 'channel'
name: publish channel (${{ inputs.package }}) to npm
name: publish channel (${{ inputs.package }}) to npm + binary
runs-on: ubuntu-latest
permissions:
contents: read # no tag, no Release; just publish
contents: write # create prerelease GitHub Release with binary assets
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- name: Require channel input
if: ${{ inputs.channel == '' }}
run: |
echo "::error::mode=channel requires the workflow input \"channel\" (npm dist-tag, e.g. mcp / plugin / sync-release). Leave mode=stable if you do not need a dist-tag."
exit 1
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
@@ -87,7 +115,26 @@ jobs:
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Ensure zip (per-platform binary archives)
run: sudo apt-get update && sudo apt-get install -y zip
- run: pnpm install --frozen-lockfile
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.19"
- name: publish-channel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# OSS release channel — same Settings-injected values as stable.
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
+6
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
# Dependencies & build output
node_modules
dist
dist-bin
dist-ssr
tools/generated
.node-version
@@ -36,7 +37,9 @@ tools/generated
.claude/settings.local.json
.claude/scheduled_tasks.lock
.cursor/
.qoder/
.qwen/
.qoder
.playwright-mcp/
.pnpm-store/
@@ -46,3 +49,6 @@ packages/cli/scene/**/outputs/
# Environment variables (sensitive data)
.env
# Local scratch / plan drafts (never commit)
.scratch/
+10 -1
View File
@@ -5,6 +5,15 @@ set -eu
pnpm run sync:skill-assets
# Stage generator output so it is included in this commit.
git add skills/bailian-cli/reference skills/bailian-cli/SKILL.md
git add \
skills/bailian-protocol/SKILL.md \
skills/bailian-cli/SKILL.md \
skills/bailian-cli/reference \
skills/bailian-gen/SKILL.md \
skills/bailian-gen/reference \
skills/bailian-finetune/SKILL.md \
skills/bailian-finetune/reference \
skills/bailian-managed-agent/SKILL.md \
skills/bailian-managed-agent/reference
vp staged
+22 -20
View File
@@ -35,7 +35,7 @@ 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`**`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` 执行。
Skill / 命令手册随 `skills/bailian-*/``npx skills add modelstudioai/cli --all -g` 安装(整包装齐,含共享协议 `bailian-protocol`)。业务 skill`bailian-cli` / `bailian-gen` / `bailian-finetune` / `bailian-managed-agent`)执行前读 `skills/bailian-protocol/`;不要依赖 frontmatter `companions`(安装器不强制)`tools/generate-reference.ts`**`packages/cli/src/commands.ts`** 按一级命令归属表分流写入各 `skills/<skill>/reference/`(纳入 git);`tools/sync-skill-metadata.ts``packages/cli/package.json` 同步 `skills/*/SKILL.md``metadata.version`。两者由根脚本 `pnpm run sync:skill-assets``.vite-hooks/pre-commit` 执行。hub `bailian-cli` 的路由表不复述领域命令明细SKILL 文案 / 安装约定 / hand-off 见 [docs/agents/skill-change.md](docs/agents/skill-change.md)。
约定:
@@ -48,31 +48,33 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
非代码资产:
- `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`
- `tools/generate-reference.ts` — 从 `packages/cli/src/commands.ts` 按归属表生成 `skills/<skill>/reference/`
- `tools/sync-skill-metadata.ts` — 同步 `skills/*/SKILL.md``metadata.version`(含 `bailian-protocol`
- `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) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [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) |
| 场景 | 何时进入 | 详见 |
| ----------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- |
| 命令增删改 | 增加 / 删除 / 重命名 `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) |
| Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.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) |
| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-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) |
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [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`,把清单沉淀下来。
+49
View File
@@ -6,6 +6,55 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.14.1] - 2026-08-05
### Added
- **Focused Bailian Skills** — `npx skills add modelstudioai/cli --all -g` now installs dedicated skills for media generation, fine-tuning, Managed Agent, and shared execution rules, improving task routing while reducing irrelevant context.
### Changed
- **Default image model upgraded to Qwen-Image 3.0** — image generation, image editing, pipelines, the config UI, and related documentation now default to `qwen-image-3.0` for API Key users.
- **Broader coding-agent compatibility** — Skill installation and updates now detect more coding agents, preserve existing installation links, and automatically backfill skills into newly detected agents.
## [1.14.0] - 2026-08-04
### Added
- **Standalone installation without Node.js** — binary packages are available for macOS on Apple Silicon and Intel, Linux x64, and Windows x64; npm installation remains supported.
- **Exact-version updates** — binary and npm installations can use `bl update --to <version>` to update or switch to a specified version.
### Changed
- **Binary self-updates** — binary installations now check and download updates through a dedicated release channel. `bl update` no longer replaces the running executable, and the next invocation automatically uses the new version.
## [1.13.1] - 2026-08-03
### Changed
- **Default text model upgraded to Qwen3.8-Max** — `bl text chat`, pipelines, API key validation, the config UI, and Managed Agent init templates now default to `qwen3.8-max`; Token Plan also moves from the preview model to the stable release.
## [1.13.0] - 2026-07-30
### Added
- **`bl config ui` Skills / MCP / Agents / Assets inventory** — browse installed skills, MCP servers, coding agents, and generated assets in the local Web UI with click-to-open detail drawers:
- Skills: render `SKILL.md` as Markdown (GFM tables supported), show local vs remote origin badges, and install a skill by uploading a `.zip` archive into any supported agent's skills root.
- MCP: view and edit JSON configuration with secret masking and mask-preserving writes; create, update, and delete MCP entries across Claude Code, Qwen Code, OpenCode, Cursor, Windsurf, Gemini, Qoder Work, OpenClaw, and Claude Desktop.
- Agents: quick-launch coding agents directly from the UI (gated on the CLI binary being on PATH).
- Assets: categorized, time-sorted browser with preview, open-locally, and delete.
- **Model catalog suggestion chips** — per-category model names surfaced as click-to-fill chips under each `default_*_model` field in the config UI.
- **Profiles tile grid** — profiles displayed as a tile grid with an add-tile and a design-consistent new-profile modal.
### Changed
- Config UI layout: collapsible grouped sidebar with icons and persistent state, responsive breakpoint, wider main area, sticky view headers, and right-side drawers for editing.
### Fixed
- Symlinked skill directories are now correctly identified as an installed source.
- Config file detection now supports environment-variable-based paths and legacy configuration schemes.
## [1.12.0] - 2026-07-28
### Added
+49
View File
@@ -6,6 +6,55 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.14.1] - 2026-08-05
### 新增
- **百炼 Skill 按领域拆分** —— 通过 `npx skills add modelstudioai/cli --all -g` 可统一安装图片与视频生成、模型微调、Managed Agent 和共享执行协议等专用 Skill提升任务路由准确性并减少无关上下文。
### 变更
- **默认图片模型升级至 Qwen-Image 3.0** —— 普通 API Key 用户的图片生成、图片编辑、Pipeline、配置 UI 和相关文档现在默认使用 `qwen-image-3.0`
- **扩展 Coding Agent 兼容范围** —— Skill 安装与更新现在能够识别更多 Coding Agent保留已有安装链接并自动将 Skill 补充到新识别的 Agent。
## [1.14.0] - 2026-08-04
### 新增
- **免 Node.js 的二进制安装** — 支持 macOS Apple Silicon / Intel、Linux x64 和 Windows x64npm 安装方式继续保留。
- **指定版本更新** — 二进制和 npm 安装均可通过 `bl update --to <version>` 更新或切换到指定版本。
### 变更
- **二进制自更新** — 二进制安装现在通过独立的发布通道检查和下载更新;执行 `bl update` 时不会覆盖正在运行的程序,下次运行自动使用新版本。
## [1.13.1] - 2026-08-03
### 变更
- **默认文本模型升级至 Qwen3.8-Max** — `bl text chat`、Pipeline、API Key 登录校验、配置 UI 和 Managed Agent 初始化模板现在默认使用 `qwen3.8-max`Token Plan 也由预览版切换至正式版。
## [1.13.0] - 2026-07-30
### 新增
- **`bl config ui` 技能 / MCP / 代理 / 资产清单** — 在本地 Web UI 中浏览已安装的技能、MCP 服务器、编码代理和生成的资产,点击打开右侧详情抽屉:
- 技能:将 `SKILL.md` 渲染为 Markdown支持 GFM 表格),展示本地/远程来源徽章,支持上传 `.zip` 压缩包将技能安装到任意受支持代理的技能目录。
- MCP查看和编辑 JSON 配置,支持密钥掩码与掩码保真写回;支持在 Claude Code、Qwen Code、OpenCode、Cursor、Windsurf、Gemini、Qoder Work、OpenClaw 和 Claude Desktop 中创建、更新、删除 MCP 条目。
- 代理:从 UI 一键启动编码代理(需对应 CLI 二进制在 PATH 中)。
- 资产:按类别分组、按时间排序的浏览器,支持预览、本地打开和删除。
- **模型目录建议芯片** — 在配置 UI 的每个 `default_*_model` 字段下方展示按类别分组的模型名称,点击即可填入。
- **Profile 磁贴网格** — 配置文件以磁贴网格展示,新增添加磁贴和设计一致的新建 Profile 弹窗。
### 变更
- 配置 UI 布局:可折叠分组侧边栏(带图标和持久化状态)、响应式断点、更宽的主区域、吸顶视图标题、右侧抽屉式编辑。
### 修复
- 修复软链接技能目录未被正确识别为已安装来源的问题。
- 配置文件检测现支持基于环境变量的路径和旧版配置方案。
## [1.12.0] - 2026-07-28
### 新增
+58 -76
View File
@@ -1,58 +1,71 @@
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**npm 包名 `bailian-cli`命令 `bl` / `bailian`)。用户通常通过 **npm 全局安装** 使用,**无需**访问本仓库源码。不要臆造版本号或路径;以用户环境为准。
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node若环境已有 Node / 需要 Command Pack再用 npm。不要臆造版本号或路径;以用户环境为准。
## 1. 安装前检查
## 1. 推荐:二进制安装(无需 Node
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
```
1. **Node.js**
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
可选:正式安装读 CDN 上的 `manifest.json`。预发 / channel 验证一律读 `sync-release.json`bailian-cli 的 channel 发版都会覆盖它):
2. **npm****唯一**允许的包管理器)
- 执行 `npm -v` 确认可用。
- 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`
- 即使本机已安装 `pnpm``yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash -s -- --channel sync-release
```
3. **网络**
- 需能访问 npm registry默认 **registry.npmjs.org**)。
- 不可达:由 Agent 自行配置镜像或代理后重试。
也可用 `--version` / `--cdn`(或环境变量 `BAILIAN_CLI_CDN`)覆盖资源根。安装脚本本身不在本仓库维护。
---
Windows PowerShell
## 2. 安装 CLI 与 Skills
```powershell
# 正式manifest.json
irm https://bailian.aliyun.com/cli/install.ps1 | iex
**仅允许以下命令**(不要用 `pnpm add -g``yarn global add` 等)。按顺序执行,上一步通过后再进行下一步。
# channel / 预发验证sync-release.json
$env:BAILIAN_CHANNEL = 'sync-release'; irm 'https://bailian.aliyun.com/cli/install.ps1' | iex
```
**2.1 安装 CLI**
带其它参时先落盘再执行(`irm | iex` 不便传参),或使用仓外静态资源文档中的预发入口。
二进制安装布局为 `versions/<ver>/` + `current` 指针;`bl update` 只切换指针并清理旧版本(保留当前与上一版)。更新进程退出后,下次执行 `bl` 即使用新版本(无需「重启应用」)。
校验:
```bash
bl --version
which bl # Windows: where.exe bl
```
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。
## 2. 备选npm 安装(要求 **≥ 18.17.0**
1. `node -v` 确认版本。
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn
3. 执行:
```bash
npm install -g bailian-cli
```
安装成功后,应能在 PATH 中找到:
4. 校验:`bl --version`
- `bl`(短别名)
- `bailian`(全名)
**校验**Agent 应执行并检查退出码与输出):
```bash
bl --version
which bl # Windows 可用 where bl
```
`command not found`:检查全局 bin 是否在 PATH`npm config get prefix`,其下 `bin` 目录应加入 PATH
**2.2 安装 Skills**
CLI 校验通过后,在本机终端执行:
可选 skills与 CLI 本体无关,按需):
```bash
npx skills add modelstudioai/cli --all -g
```
**Supported** 始终使用 `--all -g`,一次装齐整套 `bailian-*`(含共享协议 `bailian-protocol`。Agent Skills / `npx skills` **不会**按 metadata 自动拉依赖。
**Advanced / 不推荐:** 子集 `-s` 时 skills CLI 不会自动带上 `bailian-protocol`;若坚持子集,必须手动同时指定,例如:
```bash
# Advanced: you MUST include bailian-protocol yourself — installer does not pull it
npx skills add modelstudioai/cli -g -s bailian-protocol -s bailian-gen
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
---
@@ -61,39 +74,14 @@ npx skills add modelstudioai/cli --all -g
### 推荐:浏览器登录(控制台会话)
适用于本机交互式安装,无需用户手动复制 API Key
1. 执行 `bl auth status --output json`,判断是否已配置。
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`;命令会拉起浏览器完成阿里云控制台登录授权
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`
3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
### 备选API Key / Token Plan
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
- 获取入口:[百炼控制台 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
1. 执行 `bl auth status --output json`,判断是否已配置。
2. 若未配置或后续 API 校验失败,**请用户粘贴 API Key**(可说明从上述控制台复制;勿要求用户发到公开渠道)。
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`,勿在对话中向用户解释底层命名。
- **写入配置文件**(持久化,与 `auth login` 落盘相同):`bl config set --key api_key --value <key>``--key api-key` 亦可)。**不会**像 `bl auth login --api-key` 那样先校验 Key 是否可用Agent 引导安装时仍**优先**用 `auth login`
- **命令行临时传入**:需要 API Key 的 `bl` 子命令可在**当次**执行附加全局 `--api-key <key>`,仅本次生效、不落盘(例:`bl text chat --api-key sk-xxx --message "你好"`)。与上文持久化方式不是同一用途。
- 普通 Key`bl auth login --api-key <Key>`
- Token Plan`bl auth login --config token-plan --api-key <Key>`
### Agent 安全约束
@@ -104,22 +92,16 @@ npx skills add modelstudioai/cli --all -g
## 4. 配置验证
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
```bash
bl auth status --output json
```
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
## 5. 常见问题
---
## 5. 常见问题Agent 排障清单)
| 现象 | 可能原因 | 建议动作 |
| ----------------------- | -------------------- | --------------------------------------------------------------- |
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
| 现象 | 可能原因 | 建议动作 |
| ------------------------ | ---------------------------- | ------------------------------------------------ |
| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin``npm prefix -g` |
| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` |
| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 |
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node或改用 npm 版 CLI |
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 |
+23 -5
View File
@@ -24,9 +24,9 @@ _Built for AI Agents. Every command works as a structured tool call._
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Text chat** — Qwen3.8-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
- **Image generation & editing** — Qwen-Image 3.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **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 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
@@ -41,6 +41,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **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`)
- **Asset center** — Browse and manage model-generated assets (`asset-center list/get/download`), favorites and recycle bin (`favorite`/`delete`), and storage quota (`stats`/`storage`)
- **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
@@ -77,11 +78,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
## Installation
```bash
# Recommended — no Node required
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# Windows (PowerShell)
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 18.17.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
@@ -140,6 +150,13 @@ bl quota check # Current usage vs rate li
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota-change history
# Asset center — browse, download, and manage model-generated assets (requires console login)
bl asset-center list --type IMAGE
bl asset-center get <asset-id> --include-download-url
bl asset-center download --id <asset-id>
bl asset-center stats
bl asset-center storage
# 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
@@ -177,7 +194,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### Console Login (OAuth)
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.
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`, `asset-center *`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
@@ -210,8 +227,9 @@ 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
# Self-update to latest version
# Self-update to latest or a specific version
bl update
bl update --to 0.1.14
```
Config file location: `~/.bailian/config.json`
+24 -4
View File
@@ -24,9 +24,9 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **文本对话** — Qwen3.8-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **图像生成与编辑** — Qwen-Image 3.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
@@ -41,6 +41,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`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`
- **资产中心** — 管理模型生成资产(`asset-center list/get/download`)、收藏与回收站(`favorite`/`delete`)、容量统计(`stats`/`storage`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
@@ -75,11 +76,20 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 安装
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# WindowsPowerShell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 18.17
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留
## 快速开始
@@ -138,6 +148,13 @@ bl quota check # 当前用量 vs 限流
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
# 资产中心 — 浏览、下载与管理模型生成资产(需控制台登录)
bl asset-center list --type IMAGE
bl asset-center get <asset-id> --include-download-url
bl asset-center download --id <asset-id>
bl asset-center stats
bl asset-center storage
# Token Plan 团队版管理(需 AK/SK见下方认证说明
bl token-plan list-seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
@@ -175,7 +192,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### 控制台登录OAuth
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history``asset-center *`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
@@ -210,6 +227,9 @@ bl config set --key timeout --value 600
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
+574
View File
@@ -0,0 +1,574 @@
# bailian-cli 快速上手指南
> 本文档面向新加入项目的开发者,帮助你理解 monorepo 的整体架构、代码组织方式和日常开发流程。
> AI Agent 维护契约见根目录 [`AGENTS.md`](../AGENTS.md);各场景的详细清单见 [`docs/agents/`](agents/)。
---
## 1. 项目是什么
**bailian-cli** 是阿里云百炼DashScope / Model Studio平台的命令行工具让用户和 AI Agent 通过终端调用平台的全部 AI 能力:
- 文本/全模态对话、图像/视频生成与编辑、语音合成与识别
- 知识库检索、记忆管理、应用调用、MCP 集成
- 微调与部署、数据集管理、配额与业务空间
- 控制台能力(用量统计、限流提额、资产中心等)
仓库以 **pnpm monorepo** 组织,产出两个 npm 产品:
| 产品 | 包名 | 二进制 | 定位 |
| -------------- | ---------------------- | ---------------- | ------------------------------------ |
| 百炼全量 CLI | `bailian-cli` | `bl` / `bailian` | 暴露全部命令 |
| 知识库轻量 CLI | `knowledge-studio-cli` | `kscli` | 仅 config + knowledge 命令,路径拍平 |
---
## 2. 技术栈
| 类别 | 选型 |
| --------- | -------------------------------------------------------------------------------------------- |
| 语言 | TypeScriptstrict |
| 运行时 | Node.js ≥ 22.12 |
| 包管理 | pnpm 10 + workspace catalog |
| 构建/测试 | [vite-plus](https://github.com/voidzero-dev/vite-plus)`vp check` / `vp test` / `vp pack` |
| HTTP | undici经 core client 封装) |
| 模块 | ESM`"type": "module"` |
---
## 3. 核心架构:四层分层
项目按 **「纯逻辑 → 运行时框架 → 命令库 → 产品入口」** 严格分层,职责边界清晰:
```
┌─────────────────────────────────────────────────────────────────┐
│ 产品入口层 │
│ packages/cli (bl) packages/kscli (kscli) │
│ 决定命令路径 map、产品 identity、README、技能 reference │
└────────────────────────────┬────────────────────────────────────┘
│ createCli(commands, identity)
┌────────────────────────────▼────────────────────────────────────┐
│ 运行时框架层 packages/runtime (bailian-cli-runtime) │
│ 参数解析、命令树/registry、help、middleware、错误处理、输出 │
└────────────────────────────┬────────────────────────────────────┘
│ 调用 defineCommand 的 run()
┌────────────────────────────▼────────────────────────────────────┐
│ 命令库层 packages/commands (bailian-cli-commands) │
│ 96+ 命令实现;只导出 command不决定产品路径 │
└────────────────────────────┬────────────────────────────────────┘
│ client / settings / auth
┌────────────────────────────▼────────────────────────────────────┐
│ 纯逻辑层 packages/core (bailian-cli-core) │
│ 鉴权、配置、HTTP client、错误、类型、文件工具、领域 API │
└─────────────────────────────────────────────────────────────────┘
```
### 分层边界(必须遵守)
| 层 | 可以做 | 不能做 |
| --------------- | --------------------------- | ------------------------------------------------------------- |
| **core** | 纯库逻辑、HTTP、鉴权解析 | 依赖 runtime/commands硬编码 `bl`/`kscli`;调 `process.exit` |
| **runtime** | TTY、help、middleware、输出 | 写具体业务命令逻辑 |
| **commands** | 命令元数据 + `run` 实现 | 决定产品路径;在 usage 里写 bin 前缀 |
| **cli / kscli** | 命令路径 map、产品 identity | 不写命令业务逻辑 |
---
## 4. 包详解
### 4.1 `packages/core` — `bailian-cli-core`
纯逻辑层,被所有上层依赖。主要模块:
```
packages/core/src/
├── auth/ # API Key / Console token 解析与落盘
├── client/ # HTTP client、endpoints、MCP、流式解析
├── config/ # ~/.bailian/config.json、Settings、来源优先级
├── console/ # Console Gateway 调用
├── dataset/ # 数据集校验ChatML/DPO/CPT schema
├── finetune/ # 微调 API 与能力探测
├── deploy/ # 部署 API
├── advisor/ # 模型推荐(意图识别 + 召回)
├── errors/ # BailianError、UsageError、退出码
├── output/ # JSON/text 格式化(命令层也可用 runtime 的 emit
├── files/ # 本地文件上传、URL 解析
├── telemetry/ # 命令执行遥测
└── types/ # Command、FlagsDef、defineCommand
```
**关键类型** — 每个命令通过 `defineCommand` 声明:
```typescript
defineCommand({
description: "…",
auth: "apiKey" | "console" | "none",
flags: {
/* camelCase key → kebab-case CLI flag */
},
usageArgs: "--prompt <text> [flags]", // 不含 bl/kscli 前缀
exampleArgs: ['--prompt "hello"'],
validate: (flags) => string | undefined, // 跨 flag 校验
run: async (ctx) => {
/* ctx.client / ctx.flags / ctx.settings */
},
});
```
**Client** 是命令的网络入口,凭证已注入,命令层不碰 token
```typescript
ctx.client.requestJson({ path: "/…", method: "POST", body });
ctx.client.console({ product: "…", action: "…", params });
ctx.client.uploadFile(localPath);
ctx.client.mcp();
```
### 4.2 `packages/runtime` — `bailian-cli-runtime`
通用 CLI 框架,与具体业务无关。核心文件:
| 文件 | 职责 |
| ------------------ | ----------------------------------------------- |
| `create-cli.ts` | 入口工厂:`createCli(commands, identity).run()` |
| `registry.ts` | 从 `Record<string, AnyCommand>` 建树,动态 help |
| `args.ts` | 路径 + flag 解析 |
| `middleware.ts` | auth → telemetry → versionCheck → runCommand |
| `error-handler.ts` | 统一错误输出与退出码 |
| `urls.ts` | 用户面控制台 URL非 API endpoint |
| `output/` | 颜色、表格、进度条、banner |
| `pipeline/` | 多步 pipeline 编排(`bl pipeline run` |
**Middleware 流水线**(洋葱模型):
```
argv 解析
→ authStage 按 command.auth 注入 apiKey / console 凭证到 ctx.client
→ telemetryStage 记录命令执行
→ versionCheckStage 检查 npm 更新
→ runCommandStage 调用 command.run(ctx)
```
### 4.3 `packages/commands` — `bailian-cli-commands`
命令实现库,按**能力域**组织目录(≠ 最终 CLI 路径):
```
packages/commands/src/commands/
├── text/ # 文本对话
├── omni/ # 全模态对话
├── image/ # 图像生成/编辑
├── video/ # 视频生成/编辑/下载
├── speech/ # 语音合成/识别
├── vision/ # 图像/视频理解
├── knowledge/ # 知识库检索/搜索/对话
├── memory/ # 记忆管理
├── app/ # 应用调用
├── mcp/ # MCP 服务
├── auth/ # 登录/登出/状态
├── config/ # 配置读写
├── console/ # 通用 Console Gateway 调用
├── dataset/ # 数据集上传/校验
├── finetune/ # 微调任务
├── deploy/ # 模型部署
├── quota/ # 限流与提额
├── workspace/ # 业务空间
├── usage/ # 用量统计
├── advisor/ # 模型推荐
├── asset-center/ # 资产中心(新)
├── pipeline/ # Pipeline 编排
├── search/ # 联网搜索
├── file/ # 文件上传
├── token-plan/ # Token 计划
└── update.ts # 自更新
```
每个命令文件 `export default defineCommand(…)`,并在 `packages/commands/src/index.ts` 具名 re-export。
### 4.4 `packages/cli` — `bailian-cli``bl`
产品入口,极薄:
```typescript
// packages/cli/src/main.ts
createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
}).run();
```
**命令路径由 `packages/cli/src/commands.ts` 决定**,例如:
```typescript
export const commands: Record<string, AnyCommand> = {
"text chat": textChat,
"asset-center list": assetList,
"finetune create": finetuneCreate,
update, // 单级命令 key 即路径
};
```
此文件还被 `tools/generate-reference.ts` 读取,生成 Agent Skill 参考文档。
### 4.5 `packages/kscli` — `knowledge-studio-cli``kscli`
轻量 RAG 产品,**复用同一套 commands**,但路径拍平:
```typescript
const commands = {
retrieve: knowledgeRetrieve, // ↔ bl knowledge retrieve
search: knowledgeSearch, // ↔ bl knowledge search
chat: knowledgeChat, // ↔ bl knowledge chat
"config show": configShow,
update,
};
```
同一个 `knowledgeRetrieve` 实现,在 `bl` 显示 `bl knowledge retrieve`,在 `kscli` 显示 `kscli retrieve`——路径完全由产品入口 map 的 key 决定。
---
## 5. 一次命令执行的完整链路
`bl text chat --message "hi"` 为例:
```mermaid
sequenceDiagram
participant User
participant main as cli/main.ts
participant createCli as runtime/create-cli.ts
participant registry as runtime/registry.ts
participant mw as middleware
participant cmd as commands/text/chat.ts
participant client as core/client
User->>main: bl text chat --message "hi"
main->>createCli: createCli(commands, identity).run(argv)
createCli->>registry: 解析路径 ["text","chat"]
registry-->>createCli: 匹配 textChat command
createCli->>mw: authStage → 注入 apiKey 到 client
mw->>cmd: run(ctx)
cmd->>client: requestJson / parseSSE
client-->>User: stdout 输出
```
**配置与凭证解析优先级**core 统一处理,命令不介入):
| 来源 | API Key | Console Token |
| ---- | ----------------------- | ------------------------------------- |
| 1 | `--api-key` flag | `~/.bailian/config.json` access_token |
| 2 | `DASHSCOPE_API_KEY` env | — |
| 3 | config.json `api_key` | — |
Console 命令额外有 `--console-region``--workspace-id` 等 flag由 runtime 按 `auth: "console"` 自动展示)。
---
## 6. 鉴权域
每个命令声明 `auth` 字段runtime 自动处理:
| auth 值 | 适用场景 | 凭证来源 | 网络方法 |
| ----------- | ------------------------------ | -------------------- | -------------------------------- |
| `"apiKey"` | DashScope API模型推理等 | API Key | `client.request` / `requestJson` |
| `"console"` | Console Gateway控制台能力 | Console access token | `client.console` |
| `"none"` | 纯本地config、update、help | 无 | 可选 credential-less client |
**规则**:调用 Console Gateway 的命令必须 `auth: "console"`,且**不要**重复声明 console 凭证域 flags。
---
## 7. 错误处理约定
CLI **只翻译自己能权威解释的错误**,服务端错误原样透传:
| 错误来源 | 处理 |
| ---------------------- | -------------------------------- |
| 缺参、flag 校验 | `UsageError` → 退出码 2 |
| 本地无凭证 | `BailianError(AUTH)` |
| 网络/DNS/TLS | `BailianError(NETWORK)` |
| HTTP 4xx/5xx、业务错码 | message **原样透传**,不二次包装 |
---
## 8. 开发工作流
### 8.1 环境准备
```bash
# 要求 Node >= 22.12, pnpm >= 10
pnpm install
# 格式化 + lint + 类型检查
pnpm run check # 或 vp check
# 本地跑 bltsx 直跑,无需 build
pnpm run bl -- text chat --help
pnpm run kscli -- search --help
# 全量测试
pnpm test # 或 vp test
# 构建所有包
pnpm run ready # check + test + build
```
### 8.2 新增一个 `bl` 命令(最小路径)
假设新增 `bl widget do`
**Step 1** — 实现命令(`packages/commands`
```bash
# 新建
packages/commands/src/commands/widget/do.ts
```
```typescript
import { defineCommand, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const FLAGS = {
name: { type: "string", valueHint: "<name>", description: "Widget name", required: true },
} satisfies FlagsDef;
export default defineCommand({
description: "Do something with a widget",
auth: "apiKey", // 或 "console" / "none"
flags: FLAGS,
usageArgs: "--name <name>",
exampleArgs: ['--name "demo"'],
async run(ctx) {
const data = await ctx.client.requestJson({ path: "/…", method: "POST", body: { } });
emitResult(ctx, data);
},
});
```
**Step 2** — 导出(`packages/commands/src/index.ts`
```typescript
export { default as widgetDo } from "./commands/widget/do.ts";
```
**Step 3** — 注册产品路径(`packages/cli/src/commands.ts`
```typescript
import { widgetDo } from "bailian-cli-commands";
// …
"widget do": widgetDo,
```
**Step 4** — E2E 测试(`packages/cli/tests/e2e/widget.e2e.test.ts`
见 [docs/agents/cli-e2e-tests.md](agents/cli-e2e-tests.md):至少覆盖分组 help、`--help`、缺参用例。
**Step 5** — 验证
```bash
vp check
vp test
pnpm run bl -- widget do --help
```
> 若 `kscli` 也需要暴露:在 `packages/kscli/src/main.ts` 的 map 里加 key。
> 技能 reference 会在 pre-commit 时由 `generate-reference.ts` 自动从 `commands.ts` 生成。
详细清单 → [docs/agents/command-add-remove.md](agents/command-add-remove.md)
### 8.3 给已有命令加 flag
→ [docs/agents/command-flag-change.md](agents/command-flag-change.md)
---
## 9. 测试体系
```
packages/cli/tests/
├── e2e/ # 33 个 e2e 测试文件
│ ├── helpers.ts # runCli、环境变量 readiness 判断
│ ├── global-setup.ts
│ └── <topic>.e2e.test.ts
└── stress/ # 多能力并发压测
├── run.mjs
└── targets/
```
**E2E 双层结构**(固定模式):
```typescript
// 层 1永远跑 — help / 分组,无需 API Key
describe("e2e: asset-center", () => {
test("asset-center 分组展示子命令帮助且成功退出", );
test("asset-center list --help 正常退出", );
});
// 层 2skipIf 缺凭证 — dry-run / 真实集成
describe.skipIf(!isConsoleE2EReady())("e2e: asset-centerConsole …)", () => {
test("缺少 --asset-id 时退出为用法错误 (2)", );
test("真实 list 流程", );
});
```
环境变量(常用):
| 变量 | 用途 |
| --------------------------------------------- | ------------------------- |
| `DASHSCOPE_API_KEY` | 模型 API 集成测试 |
| Console token`bl auth login --console` | 控制台命令测试 |
| `BAILIAN_E2E_*` | 各能力开关(视频/媒体等) |
压测:`pnpm run test:stress`
---
## 10. 命令能力地图(`bl` 全量)
当前 `packages/cli/src/commands.ts` 注册的命令组:
| 命令组 | 子命令示例 | auth 域 |
| -------------- | ------------------------------------------------------------------------------- | ---------------- |
| `auth` | login, status, logout | none / console |
| `text` | chat | apiKey |
| `omni` | (全模态对话) | apiKey |
| `image` | generate, edit | apiKey |
| `video` | generate, edit, ref, task get, download | apiKey |
| `vision` | describe | apiKey |
| `speech` | synthesize, recognize | apiKey |
| `knowledge` | retrieve, search, chat | apiKey |
| `memory` | add, search, list, update, delete, profile create/get | apiKey |
| `app` | call, list | apiKey / console |
| `mcp` | call, list, tools | apiKey |
| `search` | web | apiKey |
| `file` | upload | apiKey |
| `config` | show, set | none |
| `console` | call | console |
| `usage` | free, freetier, stats | console |
| `workspace` | list | console |
| `quota` | list, request, history, check | console |
| `dataset` | upload, list, get, delete, validate | console |
| `finetune` | create, list, get, cancel, delete, logs, checkpoints, export, watch, capability | console |
| `deploy` | create, list, get, models, scale, update, delete | console |
| `token-plan` | list-seats, create-key, assign-seats, add-member | console |
| `asset-center` | list, get, favorite, unfavorite, delete, download, stats, storage | console |
| `pipeline` | run, validate | apiKey |
| `advisor` | recommend | apiKey |
| `update` | (自更新) | none |
---
## 11. 非代码资产
```
tools/
├── generate-reference.ts # 从 cli/commands.ts → skills/bailian-cli/reference/
├── sync-skill-metadata.ts # 同步 SKILL.md 版本号
└── release/ # CI 发版自动化
skills/bailian-cli/ # Agent Skillnpx skills add modelstudioai/cli
.github/workflows/ # CI/CDpublish.yml 等)
docs/agents/ # 各维护场景的 AI 清单
```
根脚本:
```bash
pnpm run sync:skill-assets # build + 生成 reference + 同步版本
pnpm run release:check # 发版前校验
```
---
## 12. 发布
- 版本号:`packages/core``runtime``commands``cli``kscli` **保持同步**
- 发布范围:`tools/release/lib/packages.mjs` 定义
- `bailian-cli` 走常规定义发布;`knowledge-studio-cli``--knowledge` 通道
- 详见 [docs/agents/publish.md](agents/publish.md)
---
## 13. 关键文件速查
| 我想… | 看这里 |
| ------------------- | --------------------------------------- |
| 了解项目契约 | `AGENTS.md` |
| 改 `bl` 命令路径 | `packages/cli/src/commands.ts` |
| 写/改命令逻辑 | `packages/commands/src/commands/<域>/` |
| 导出命令 | `packages/commands/src/index.ts` |
| 改 CLI 框架行为 | `packages/runtime/src/` |
| 改 HTTP/鉴权/配置 | `packages/core/src/` |
| 改 kscli 路径 | `packages/kscli/src/main.ts` |
| 加 E2E 测试 | `packages/cli/tests/e2e/` |
| 改控制台 URL | `packages/runtime/src/urls.ts` |
| 改 API endpoint | `packages/core/src/client/endpoints.ts` |
| 改配置 schema | `packages/core/src/config/schema.ts` |
| 生成 Agent 参考文档 | `tools/generate-reference.ts` |
---
## 14. 场景导航(维护清单)
| 场景 | 文档 |
| ----------- | ------------------------------------------------------- |
| 命令增删改 | [command-add-remove.md](agents/command-add-remove.md) |
| E2E 测试 | [cli-e2e-tests.md](agents/cli-e2e-tests.md) |
| 加/改 flag | [command-flag-change.md](agents/command-flag-change.md) |
| 模型上下架 | [model-add-remove.md](agents/model-add-remove.md) |
| 错误文案 | [error-hint-change.md](agents/error-hint-change.md) |
| 鉴权扩展 | [auth-change.md](agents/auth-change.md) |
| 配置项扩展 | [config-add.md](agents/config-add.md) |
| 发布 | [publish.md](agents/publish.md) |
| 工具链/lint | [lint-toolchain.md](agents/lint-toolchain.md) |
---
## 15. 架构设计要点(读懂代码的钥匙)
1. **命令实现 ≠ 产品路径** — 同一 `knowledgeRetrieve` 可以是 `bl knowledge retrieve``kscli retrieve`
2. **defineCommand 是契约**`auth` + `flags` + `run(ctx)` 是命令的全部接口;凭证和网络细节下沉到 core/runtime。
3. **registry 从 map 建树**`"asset-center list"` 等 path 自动变成命令组help 动态生成。
4. **flags 用 camelCase 定义** — runtime 渲染为 `--kebab-case``ParsedFlags<typeof FLAGS>` 提供类型安全。
5. **dry-run 是全局 flag**`--dry-run` 在 auth stage 有例外处理,命令在 `run` 开头判断 `ctx.settings.dryRun`
6. **本地路径即 URL** — 所有接受 URL 的参数同时支持本地文件路径core `files/upload` 自动上传。
7. **Console Gateway 统一入口** — 控制台 API 走 `client.console({ product, action, params })`,不散落 raw fetch。
---
## 16. 本地配置速览
配置文件:`~/.bailian/config.json`
```json
{
"api_key": "sk-…",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"access_token": "…",
"console_region": "cn-beijing",
"console_site": "domestic"
}
```
常用环境变量:
| 变量 | 说明 |
| ---------------------------- | -------------------------- |
| `DASHSCOPE_API_KEY` | 模型 API Key |
| `DASHSCOPE_BASE_URL` | API Base URL |
| `BAILIAN_WORKSPACE_ID` | 业务空间 ID |
| `HTTP_PROXY` / `HTTPS_PROXY` | 代理runtime 启动时读取) |
登录:
```bash
bl auth login # API Key
bl auth login --console # Console token扫码
bl auth status
```
---
_文档版本:基于仓库当前结构(含 `asset-center`、`kscli``packages/rag` 已演进为 `packages/kscli`。_
+1 -1
View File
@@ -121,7 +121,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
### D. 用户面文档
- [ ] `README.md` / `README.zh.md` "Authentication" 段落
- [ ] `skills/bailian-cli/reference/` 通过 `pnpm run sync:skill-assets` 重建
- [ ] `skills/<skill>/reference/` 通过 `pnpm run sync:skill-assets` 重建
### E. 测试
+1 -1
View File
@@ -56,7 +56,7 @@ git diff --name-only <base>...<head>
- [ ] **新命令 / 新 flag** 已同步到用户面文档:
- [README.md](README.md) + [README.zh.md](README.zh.md)(中英文都要,常漏 `_CN`)
- `skills/bailian-cli/reference/` + `skills/bailian-cli/SKILL.md` 通过 `pnpm run sync:skill-assets` 更新并提交
- `skills/<skill>/reference/` + 对应 `SKILL.md` 通过 `pnpm run sync:skill-assets` 更新并提交
- [ ] **`bl <cmd> --help`** 文案完整:`description` / `examples` 都填了
- [ ] **demo / quickstart**:用户可调用的新命令至少有一个示例
- [ ] **行为变化的老命令**:在 commit message / CHANGELOG 注明用户感知的差异
+1 -1
View File
@@ -95,7 +95,7 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
- [ ] `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/` 并提交
- [ ] 若改了 `usageArgs` / `flags` / `exampleArgs`,跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/<skill>/reference/` 并提交
- [ ] 子命令 `--help`(分组 help 由 bl `registry.smoke` 覆盖)
- [ ] skip 块:每个 required flag 缺参;可 dry-run 则加一条
- [ ] 至少一条真实集成(或说明为何仅 smoke不破坏已有集成用例顺序
+8 -5
View File
@@ -56,7 +56,7 @@ packages/commands/src/index.ts
- **`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**,勿手改
- **`tools/generate-reference.ts`**:pre-commit / `pnpm run sync:skill-assets` 时读 `packages/cli/src/commands.ts`,`GROUP_OWNER_SKILL` 归属表分流写到各 `skills/<skill>/reference/index.md` + `<一级命令>.md`。未显式归属的一级组默认进 `bailian-cli`。各目录**纳入 git**,勿手改。新增一级命令组若应归领域 skill,记得改归属表。
已删除/勿再引用:旧的 `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`
@@ -87,9 +87,10 @@ packages/commands/src/index.ts
### C. 文档层
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新 `skills/bailian-cli/reference/``SKILL.md``metadata.version` 并提交
- [ ] 运行 `pnpm run sync:skill-assets`(或正常 `git commit` 走 pre-commit),刷新 `skills/<skill>/reference/``SKILL.md``metadata.version` 并提交
- [ ] `README.md` / `README.zh.md`:Quick Start、命令一览、认证说明(用户向,与 help 对齐)
- [ ] `skills/bailian-cli/SKILL.md`:若安装说明或能力边界有变,同步更新
- [ ] 相关 `skills/<skill>/SKILL.md`:若安装说明或能力边界有变,同步更新;新一级命令组若属领域 skill,同步改 `tools/generate-reference.ts``GROUP_OWNER_SKILL`
- [ ] **拥有方** skill 的「When to use which command」(或等价路由表)补上新意图;hub `bailian-cli` 仅加/改 hand-off 行,**不要**把领域子命令与默认模型抄进 hub 表(约定见 [skill-change.md](skill-change.md))
### D. 测试层
@@ -105,7 +106,7 @@ packages/commands/src/index.ts
- `packages/cli/src/commands.ts` map key
- `packages/kscli/src/commands.ts` map key(如适用)
- 用户可见 hint / README / tests
- `skills/bailian-cli/reference/`(重建后检查并提交)
- `skills/*/reference/`(重建后检查并提交)
- [ ] 检查 `usageArgs` / `exampleArgs` 没有硬编码旧的 `bl <path>` 前缀
## 完成后自查
@@ -127,7 +128,9 @@ pnpm -F knowledge-studio-cli exec tsx src/main.ts <command> --help
- ✗ 只新增 `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 并提交
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖;应改 command metadata 后重新 generate 并提交
- ✗ 新一级命令组忘改 `tools/generate-reference.ts``GROUP_OWNER_SKILL` → reference 会落到 hub `bailian-cli`(未必是预期)
- ✗ 只改 reference / hub,忘改拥有方 skill 路由表;或把领域命令明细重新抄回 `bailian-cli` SKILL → 与 [skill-change.md](skill-change.md) 分层冲突
- ✗ 在 `usageArgs` / `exampleArgs` 写死 `bl text chat``kscli` 等入口复用时 help 错
- ✗ Console Gateway 命令忘设 `auth: "console"` → console flags / credential 注入都不生效
- ✗ 单 action 的子组是反模式,新增时优先拍平为两级
+1 -1
View File
@@ -30,7 +30,7 @@
### C. 文档层
- [ ] `README.md` / `README.zh.md` 如果在示例里展示了相关命令,补充新 flag
- [ ]`pnpm --filter bailian-cli run generate:reference`,让 `skills/bailian-cli/reference/` 与命令一致(勿手改;改完提交)
- [ ]`pnpm --filter bailian-cli run generate:reference`,让 `skills/<skill>/reference/` 与命令一致(勿手改;改完提交)
### D. 测试层
+1 -1
View File
@@ -43,7 +43,7 @@
- [ ] `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` 等正式对外发布时再补。
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`(或归属表指定的 skill reference;公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
验证:
+5 -2
View File
@@ -46,7 +46,9 @@
- `config list` 标识所有 Profile 与当前激活项。
- `config show``auth status` 只输出本次最终选择的 `config``config_file`,不重复携带激活状态。
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`
- `config ui` 保存时只替换 UI 管理的字段Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*``telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`UI 管理的顶层元数据(如 `active_config`)不进入 Profile block仍由写盘逻辑单独保留
- `config ui` 只读展示本地 agent 生态Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源MCP、Agents 从各 agent 本地配置读取。
- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件按分类与生成时间mtime标记支持按分类筛选、内联预览图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
## 6. 最小测试矩阵
@@ -62,7 +64,8 @@
`--config default` 成功后切回 `default`
- Console token 自动刷新不从其他 Profile 借用 AK/SK也不把新 token 写入其他 Profile。
- `config list/show/use/ui``auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
## 7. 完成检查
+4 -2
View File
@@ -26,7 +26,8 @@
### C. 命令手册
- [ ]`--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新 `skills/bailian-cli/reference/<group>.md` 并提交
- [ ]`--model` 的 description 含 default,改命令后跑 `pnpm --filter bailian-cli run generate:reference` 更新对应 `skills/<skill>/reference/<group>.md` 并提交
- [ ] 同步**拥有该命令的领域 skill**「When to use which command」表中的 Default model(现主要是 `bailian-gen`;精调相关看 `bailian-finetune` 正文示例)。hub `bailian-cli` 已瘦身,一般**不必**再写领域默认模型(见 [skill-change.md](skill-change.md))
### D. 用户面文档
@@ -49,6 +50,7 @@ pnpm -F bailian-cli exec tsx src/main.ts <command> --model <new-model> --message
## 常见漏点
- ✗ 改了命令默认模型,但 SKILL.md frontmatter 仍写老型号 → AI agent 调用时仍按老型号宣传
- ✗ 改了命令默认模型,但 SKILL.md frontmatter 或领域路由表 Default model 仍写老型号 → AI agent 调用时仍按老型号宣传
- ✗ 只改了 `reference/` / flag description,忘改 `bailian-gen`(等) SKILL 路由表
- ✗ 废弃模型时只删了代码,e2e 测试还在跑,CI 红
- ✗ 新模型 endpoint 不一致,但只改了 default,没加 endpoint 分支判断
+52 -22
View File
@@ -1,27 +1,53 @@
# 发布npm publish
# 发布npm + GitHub Release 二进制
## 触发条件
- 准备发布 channelbeta/mcp/plugin 等)或正式版到 npm
- 准备打 git tag
- 准备发布 channelmcp/plugin 等)或正式版到 npm **与** GitHub Releases 二进制
- 准备打 git tag(仅 stable
## 发布方式GitHub Actions + npm OIDC
## 发布方式GitHub Actions 总入口
发版**必须**通过 CI 完成,不要本地手动 `pnpm publish`
入口GitHub Actions → **Publish** workflow`.github/workflows/publish.yml`)→ Run workflow。
**编排关系(重要):**
```text
publish-stable.mjs / publish-channel.mjs ← 唯一发版入口
├─ npmpnpm publish
└─ binarylib/binary-release
→ binary-build
→ gh-release
→ oss-direct-upload
```
`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。
两种模式:
| 模式 | 用途 | 触发方式 |
| ------- | ------------------------------ | -------------------------------------------------- |
| channel | 发 channel 版本到指定 dist-tag | 选 mode=channel填 dist-tag 名称(如 mcp/plugin |
| stable | 正式发版到 latest | 选 mode=stable需 production environment 审批 |
| 模式 | 用途 | 触发方式 |
| ------- | --------------------------------------------------------------------------------------- | -------------------------------------------- |
| channel | npm dist-tag +(仅 bailian-cli二进制 + CDN **一律**覆盖 `sync-release.json` | mode=channelchannel 填 **npm dist-tag** |
| stable | npm latest + GitHub Release `v<ver>` + CDN **`manifest.json`**(及 `latest.json` 别名) | mode=stable需 production environment 审批 |
可选 flag`--skip-binary`(仅发 npm紧急逃生
### CDN 滚动指针bailian-cli
| 发布模式 | CDN 指针 | 本机安装 / 更新 |
| -------- | ---------------------------------- | ----------------------------------------------------------------- |
| channel | 始终覆盖 `sync-release.json` | `BAILIAN_CHANNEL=sync-release` / `install --channel sync-release` |
| stable | `manifest.json`+ `latest.json` | 默认安装 / `bl update`(无 channel |
workflow 的 `channel` 输入**只决定 npm dist-tag**(如 `mcp` / `plugin` / `sync-release`**不再**生成 `release-test.json` 这类旁路文件。
### channel 发布
1. 在 GitHub 触发 Publish workflowpackage 选 `bailian-cli``knowledge-studio-cli`mode 选 `channel`channel 填 dist-tag 名(如 `mcp`
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` 版本号 → 临时 bump 对应包集合 → 自检 → 构建 → 发布到指定 dist-tag
1. 在 GitHub 触发 Publish workflowmode 选 `channel`channel 填 npm dist-tag 名
- **`bailian-cli`**npm 发到该 tag二进制同时刷新 CDN `sync-release.json`(与 tag 名无关)。本机验证:`BAILIAN_CHANNEL=sync-release`
- **`knowledge-studio-cli`**:仅 npm自动跳过 binary不碰 `sync-release.json`
2. CI 自动:生成 `0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`UTC 到分钟;同 commit 同分钟重跑会覆盖同号)→ 临时 bump → 自检 → **npm 发到 dist-tag**bailian-cli**Bun 编二进制 + GH prerelease + 覆盖 `sync-release.json`** → 还原 package.json
3. 对应脚本:`tools/release/publish-channel.mjs`
### stable 发布
@@ -29,7 +55,7 @@
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli``knowledge-studio-cli` 发布会额外包含 `packages/kscli`
2. 在 GitHub 触发 Publish workflowpackage 选目标包集合mode 选 `stable`
3. 需要 production environment 审批人批准
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
4. CI 自动:自检 → **npm 发到 latest****推送 git tag `v<ver>`****Bun 编二进制并创建/更新 GitHub Release**bailian-cli维护 CDN **`manifest.json`** → 完成
5. 如果所选发布集合的当前版本已全部存在于 npmstable 发布会失败并提示先升级版本号如果只有部分包已发布CI 会继续补发缺失包
6. 对应脚本:`tools/release/publish-stable.mjs`
@@ -37,17 +63,17 @@
两种模式都会先跑 `check.mjs`,覆盖以下检查:
| 检查项 | 说明 |
| -------------------------------- | ------------------------------------------------------------------------------------------------ |
| `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 | 敏感信息扫描 |
| 检查项 | 说明 |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `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/<skill>/reference/`;非 channel 模式还同步 `skills/*/SKILL.md` version(含 `bailian-protocol` |
| pnpm pack | 打 tarball |
| publint | 包元数据校验 |
| gitleaks | 敏感信息扫描 |
本地可以 dry-run 验证:
@@ -59,7 +85,9 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
## CI 基础设施
- **认证**npm OIDC Trusted Publishing无 token需要 `id-token: write` 权限
- **GitHub Release**`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`stable / channel 均需)
- **Node 版本**24npm 11.5+ 才支持 OIDC token 交换)
- **Bun**`oven-sh/setup-bun`,版本钉死在 workflow 中
- **Actions 版本**checkout/setup-node/pnpm-action 均为 v6Node 24 兼容)
- **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
@@ -105,3 +133,5 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npmCI 明确报错并要求先升级版本号 |
| channel job 缺少 `contents: write` | `gh release create` 失败 |
| stable 未先推 tag 就建 Release | `--verify-tag` 失败 |
+76
View File
@@ -0,0 +1,76 @@
# Skill 文案 / 路由 / 安装约定
## 触发条件
-`skills/*/SKILL.md` 的 description、路由表、consent、安全闸、hand-off、references 落款
- 调整 `bailian-protocol` 与业务 skill 的关系,或业务 skill 之间的软 hand-off 约定
- 新增 / 拆分 / 合并 `bailian-*` 业务 skill或改 `tools/generate-reference.ts``GROUP_OWNER_SKILL` 归属(与命令增删改交叉时两边都看)
- 给业务 skill 补安装说明、README或统一「勿猜 flag → `reference/`」类约定
纯改生成物 `skills/*/reference/*.md`(由命令 metadata 驱动)→ 走 [command-add-remove.md](command-add-remove.md) / [command-flag-change.md](command-flag-change.md)**不要手改 reference**。
## 统一口径(安装)
1. **Supported install** `npx skills add modelstudioai/cli --all -g`(整包装齐,含 `bailian-protocol`
2. **`bailian-protocol` 是共享协议 skill**,业务 skill 执行前应 Read 它Agent Skills / `npx skills` **不会**按 frontmatter 自动拉依赖
3. **不要**在 frontmatter 写 `companions`也不要对外说「companions = 安装器硬依赖」
4. 子集安装(`-s`)为 **advanced / 不推荐**skills CLI 不会自动带上 protocol漏装会导致相对路径 Read 失败
## 概念图
```text
bailian-protocol ← 共享协议consent / 鉴权 / 版本 / 错误上报)
▲ 靠 --all -g 与业务 skill 同装;非安装器强制 companions
┌───────┴────────┬────────────────┬──────────────────┐
bailian-gen bailian-finetune bailian-managed-agent
(领域路由表) (领域工作流) IaC 安全闸)
│ │ │
└────────────────┼──────────────────┘
▼ 软 hand-off按 skill 名)
bailian-clihub
hub 路由表:本职命令 + 领域 hand-off 行
细节 → 各 skill reference/(生成)
```
## 必查清单
### A. 分层边界
- [ ] **整包装齐**:安装/升级文案主推 `--all -g`;业务 skill **不**声明 `companions`
- [ ] **协议读取**CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `npx skills add modelstudioai/cli --all -g`
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
- [ ] **Hub vs 领域**`bailian-cli` 的「When to use which command」只列 hub 拥有的意图;媒体 / 精调 / managed-agent 各留 hand-off 行,**不抄**领域默认模型与子命令明细
- [ ] **渐进披露**SKILL 写意图路由与领域硬规则flags / usage / examples 以 `reference/``bl <command> --help` 为准,表后保留「勿猜 flag」指向句
### B. 文案与落款一致性
- [ ] 领域 skillgen / finetune / managed-agent路由或命令表后有指向 `reference/` 的句;文末 `## references`protocol + reference与家族对齐
- [ ] description 含 WHAT + WHEN + 反触发;安装说明指向 `--all -g`,不写 companions 必装
- [ ] Quick examples 只演示本 skill 职责hub 不示范 `bl image` / `bl video` 等)
- [ ] 若改了安装方式:同步 `README.md` / `README.zh.md` / `INSTALL.md` / `skills/*/README*` / `skills/bailian-protocol/assets/setup.md` 中的 `npx skills add …` 示例(改 `INSTALL.md` 时按 [install-doc-change.md](install-doc-change.md) 同步静态页)
### C. 归属与生成
- [ ] 新一级命令组归属领域时:改 `tools/generate-reference.ts``GROUP_OWNER_SKILL`,并更新**拥有方** skill 的路由表hub 最多加一行 hand-off
- [ ]`pnpm run sync:skill-assets`(或 commit 走 pre-commit提交生成的 `reference/` 与 version 同步结果
- [ ] 默认模型若写在领域路由表(如 `bailian-gen`):与命令 default / [model-add-remove.md](model-add-remove.md) 一并核对
## 完成后自查
```sh
pnpm run sync:skill-assets
# 本地试装(测本仓库改动,勿只拉远端)
npx skills add "$(pwd)" --all -g -y
```
抽查:打开 `skills/bailian-cli/SKILL.md` 确认无领域子命令明细表、无 `companions`;打开对应领域 skill 确认有「勿猜 flag」与 hand-off。
## 常见漏点
- ✗ hub 路由表再次抄回 image / video / finetune / managed-agent 明细 → token 膨胀且与领域 skill 双份漂移
- ✗ 重新加回 `companions` 并宣称安装器硬依赖 → 与 Agent Skills / `npx skills` 合同不符
- ✗ 软 hand-off 写成硬路径 `../bailian-*/SKILL.md` 当执行前提 → 子集安装断链
- ✗ 只改 SKILL、忘改 `GROUP_OWNER_SKILL` → reference 落错 skill
- ✗ 手改 `skills/*/reference/*.md` → 下次 generate 被覆盖
- ✗ 改默认模型只动 flag description / reference忘改领域 SKILL「When to use which command」表见 [model-add-remove.md](model-add-remove.md)
+165
View File
@@ -0,0 +1,165 @@
# 埋点变更
## 触发条件
- 调整 AEM 命令事件、事件字段或参数 allowlist
- 调整 `User-Agent``x-dashscope-source-config` 或其他后端渠道标识
- 新增鉴权域、请求网关或绕开统一 Client 的网络出口
- 排查命令量、成功率、版本、鉴权域或后端渠道数据不一致
## 当前数据流
三套鉴权对应三套请求域,但不代表三套网关使用相同的后端埋点。命令侧另有一套覆盖所有实际执行命令的 AEM 客户端事件,两者必须分开理解。
```text
命令进入 run
├─ telemetryStage
│ ├─ ~/.bailian/telemetry.jsonl
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
└─ authStage
├─ apiKey → DashScope / 模型域
├─ console → Bailian Console Gateway
├─ openapi → 阿里云 OpenAPI
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
```
### 1. 三套鉴权与埋点标识
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识AEM |
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| `auth: "apiKey"` | API KeyDashScope / OpenAI-compatible 模型域 | `Client.request/requestJson``McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent``x-dashscope-source-config` | 有:`pid=bailian-cli-node``authMethod=apiKey` |
| `auth: "console"` | Console access tokenBailian Console Gateway | `callConsoleGateway()``/cli/api.json` | 无 | 有:`pid=bailian-cli-node``authMethod=console` |
| `auth: "openapi"` | AccessKey ID/Secret可选 STS token阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node``authMethod=openapi` |
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node``authMethod=none` |
`authMethod` 记录的是命令声明的鉴权域,不是凭证来源。它不会区分 API Key 来自 flag、env 还是 config。
鉴权域是命令的准入门槛和主请求域,不保证命令内部只有一种网络出口;例如部分 `apiKey` 命令也可能读取匿名 Console 公共目录Managed Agent 还可能访问其他 provider。
表中的后端埋点按该鉴权域的主要业务请求填写:
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product``action``api` 是鉴权或路由字段,不计为埋点标识
### 2. 后端渠道参数
当前 `x-dashscope-source-config` 结构为:
```json
{
"channel": "bailian-cli",
"tags": {
"t1": "public",
"t2": "bl 或 kscli",
"t3": "实际 CLI 版本"
}
}
```
- `t2` 取产品 `identity.binName`:完整 CLI 为 `bl`Knowledge Studio CLI 为 `kscli`
- `t3` 取产品 `identity.version`,由产品入口的 `package.json` 注入
- `channel``t1` 是当前固定口径
- `User-Agent` 是独立标识:`bl``bailian-cli/<version>``kscli``knowledge-studio-cli/<version>`
source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网络传输:
| 请求 | source-config |
| ------------------------------------ | ------------- |
| 模型 API、任务提交与轮询 | 有 |
| Bailian MCP / OpenAPI | 有 |
| DashScope 上传策略 `getPolicy` | 有 |
| OSS 文件上传 | 无 |
| 图片、视频、音频、转录结果下载 | 无 |
| npm / 二进制更新检查、Skill registry | 无 |
当前已知例外Pipeline runtime 自建的 `Identity.version``0.0.0-dev`,因此 Pipeline 内部模型请求的 `t3` 不代表产品包版本;现阶段不纳入本轮收敛。
### 3. 全命令 AEM 客户端埋点
`packages/runtime/src/middleware.ts``telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`
- 根帮助、子命令 `--help``--version`
- 未识别命令、参数解析失败、缺少必填参数
- `defineCommand.validate` 在 dispatch 阶段拒绝的请求
遥测默认开启;`DO_NOT_TRACK=1` 一票否决,配置文件 `telemetry: false` 也可关闭。关闭后本地和远端均不记录。
单条 `TrackingEvent` 当前包含:
- `command``timestamp``durationMs``success`
- `cliVersion``nodeVersion``os`
- `authMethod`
- 失败时的 `errorMessage``httpStatus``requestId`
- 安全 allowlist 过滤后的 `params`
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts``PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
事件同时写入两处:
1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建
2. AEM`pid=bailian-cli-node`,源码运行自动使用 `env=dev`npm 安装或编译二进制使用 `env=prod`
底层 Node tracker 还会附加公共设备字段OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`
当前 AEM 事件没有 `binName``clientName` 产品维度,并且 `bl``kscli` 共用 `pid=bailian-cli-node`。两边相同路径的 `config show``config set``update` 无法仅凭当前事件稳定区分产品Knowledge 命令虽然因路径映射不同而表现为 `knowledge chat``chat`,也不应把命令路径当作长期产品标识。后端 source-config 的 `t2` 已能区分 `bl/kscli`,但这个维度尚未进入 AEM 客户端事件。
AEM 映射:
| AEM 字段 | 内容 |
| ---------- | ----------------------------------------- |
| event name | 命令路径 |
| `et` | `EXP` |
| `ext` | 除 `command``params` 外的结构化事件字段 |
| `c1` | allowlist 参数 |
| `c2` | `success` / `failure` |
| `c3` | HTTP status |
| `c4` | 错误文案,最多 500 字符 |
| `c5` | request ID |
远端发送是 best-effort不得阻塞命令或改变退出码。正常退出最多等待 1 秒SIGINT 最多等待 500 ms。
## 必查清单
### A. 新增或调整命令
- [ ] `defineCommand({ auth })` 必须声明真实请求域AEM 的 `authMethod` 直接读取该值
- [ ] 新命令进入 `run` 后自动有基础事件,不得在命令内重复发送同名事件
- [ ] 需要按产品分析 AEM 数据时,必须显式设计产品字段;不得从命令路径推断 `bl/kscli`
- [ ] 只有可枚举、数值或布尔等低风险字段才可加入 `PARAM_ALLOWLIST`
- [ ] 新增 console raw API flag 时只允许记录公开 API 名,不得记录请求 `data`
### B. 调整后端渠道参数
- [ ] 同时核对 `packages/core/src/client/http.ts``mcp.ts``instrumented-fetch.ts``client.ts``files/upload.ts`
- [ ] 产品身份必须来自 `Identity`;不得从命令路径、环境变量或 `process.argv` 猜测
- [ ] `bl``kscli` 必须分别验证 `binName``clientName``version`
- [ ] OSS、结果文件、npm、二进制和 Skill 下载不得为了业务渠道统计新增 source-config
- [ ] 改 URL / host 范围时同时执行 [URL / 渠道变更](url-change.md) 清单
### C. 调整 AEM 事件
- [ ] 更新 `TrackingEvent``createTrackingEvent()``buildRemoteAemOptions()` 的字段映射
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
- [ ] 检查 `DO_NOT_TRACK=1``telemetry: false` 两个关闭入口
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
## 完成后自查
```sh
rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts'
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions" packages/core packages/runtime --glob '*.ts'
vp check
vp test packages/core/tests packages/commands/tests/e2e/auth.e2e.test.ts
```
## 常见漏点
- ✗ 只看 AEM 命令事件,误以为它能替代网关侧请求渠道统计
- ✗ 把 `authMethod` 当成实际凭证来源;它只是命令声明的鉴权域
- ✗ 新增 bypass `fetch` 后漏掉应由网关消费的 source-config或把它发给 OSS / npm / 第三方下载地址
- ✗ 只改 `bl` 入口,导致 `kscli` 的产品名或版本标签错误
- ✗ 把帮助、版本或参数校验失败算进“全部命令”;这些路径当前没有进入 telemetry middleware
+1 -1
View File
@@ -51,7 +51,7 @@ grep -rnE "https://dashscope[a-z-]*\.aliyuncs\.com" packages/ --include="*.ts" \
### B. 非 TS 文件(只能人工同步,无法 import)
- [ ] `skills/bailian-cli/reference/``<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对并提交)
- [ ] `skills/*/reference/``<group>.md` 中 API/控制台 URL(`generate:reference` 重建后核对并提交)
- [ ] `README.md` / `README.zh.md` 中所有 URL
### C. 渠道追踪参数
+1
View File
@@ -25,6 +25,7 @@
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
"test:stress": "node packages/cli/tests/stress/run.mjs"
},
"dependencies": {},
"devDependencies": {
"tsx": "catalog:",
"vite-plus": "catalog:"
+23 -5
View File
@@ -24,9 +24,9 @@ _Built for AI Agents. Every command works as a structured tool call._
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
- **Text chat** — Qwen3.8-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
- **Image generation & editing** — Qwen-Image 3.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
- **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 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
@@ -41,6 +41,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
- **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`)
- **Asset center** — Browse and manage model-generated assets (`asset-center list/get/download`), favorites and recycle bin (`favorite`/`delete`), and storage quota (`stats`/`storage`)
- **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
@@ -77,11 +78,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
## Installation
```bash
# Recommended — no Node required
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# Windows (PowerShell)
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 18.17.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
@@ -140,6 +150,13 @@ bl quota check # Current usage vs rate li
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
bl quota history # View quota-change history
# Asset center — browse, download, and manage model-generated assets (requires console login)
bl asset-center list --type IMAGE
bl asset-center get <asset-id> --include-download-url
bl asset-center download --id <asset-id>
bl asset-center stats
bl asset-center storage
# 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
@@ -177,7 +194,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### Console Login (OAuth)
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.
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`, `asset-center *`). Opens the Bailian console in your browser to sign in.
```bash
bl auth login --console
@@ -210,8 +227,9 @@ 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
# Self-update to latest version
# Self-update to latest or a specific version
bl update
bl update --to 0.1.14
```
Config file location: `~/.bailian/config.json`
+24 -4
View File
@@ -24,9 +24,9 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **文本对话** — Qwen3.8-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **图像生成与编辑** — Qwen-Image 3.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
@@ -41,6 +41,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`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`
- **资产中心** — 管理模型生成资产(`asset-center list/get/download`)、收藏与回收站(`favorite`/`delete`)、容量统计(`stats`/`storage`
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
## 示例:一句话生成一部电影短片
@@ -75,11 +76,20 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 安装
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# WindowsPowerShell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 18.17
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留
## 快速开始
@@ -138,6 +148,13 @@ bl quota check # 当前用量 vs 限流
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
bl quota history # 查看提额历史记录
# 资产中心 — 浏览、下载与管理模型生成资产(需控制台登录)
bl asset-center list --type IMAGE
bl asset-center get <asset-id> --include-download-url
bl asset-center download --id <asset-id>
bl asset-center stats
bl asset-center storage
# Token Plan 团队版管理(需 AK/SK见下方认证说明
bl token-plan list-seats # 查看订阅席位明细
bl token-plan add-member --account-name dev --org-id org_xxx
@@ -175,7 +192,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
### 控制台登录OAuth
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history``asset-center *`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
```bash
bl auth login --console
@@ -210,6 +227,9 @@ bl config set --key timeout --value 600
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.12.0",
"version": "1.14.1",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
@@ -25,7 +25,8 @@
},
"files": [
"dist",
"README.zh.md"
"README.zh.md",
"postinstall.js"
],
"type": "module",
"exports": {
@@ -40,17 +41,19 @@
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference'",
"sync:skill-version": "tsx ../../tools/sync-skill-metadata.ts",
"build": "vp pack",
"dev": "tsx src/main.ts",
"test": "vp test",
"check": "vp check"
"check": "vp check",
"postinstall": "node postinstall.js"
},
"dependencies": {
"bailian-cli-commands": "workspace:*",
"bailian-cli-core": "workspace:*",
"bailian-cli-runtime": "workspace:*"
"bailian-cli-runtime": "workspace:*",
"tar-stream": "catalog:"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
+253
View File
@@ -0,0 +1,253 @@
/**
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
*
* Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
* package and overwrites the local directory, ensuring data is in place the first time the user runs
* `bl advisor recommend`.
*
* Flow (unified skill publishing protocol: skills/index.json + one content-addressed object per skill):
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
* legacy fallback to skill.tar.br when the entry has no valid object field
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
* 5. Write ~/.bailian/wiki-sync-state.json
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
*
* Design constraints:
* - Unconditional overwrite: every install fully replaces, no version comparison
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
*/
import { createHash } from "node:crypto";
import {
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createBrotliDecompress } from "node:zlib";
import tar from "tar-stream";
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
const CONFIG_DIR_NAME = ".bailian";
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
const STATE_FILE_NAME = "wiki-sync-state.json";
const INDEX_KEY = "index.json";
/** Legacy fixed asset key (entries without a valid content-addressed object field) */
const LEGACY_ASSET_NAME = "skill.tar.br";
/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
const INDEX_TIMEOUT_MS = 3000;
const DOWNLOAD_TIMEOUT_MS = 30000;
function getConfigDir() {
if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR;
return join(homedir(), CONFIG_DIR_NAME);
}
function getCatalogDir() {
return join(getConfigDir(), SKILL_DIR_NAME);
}
function getStatePath() {
return join(getConfigDir(), STATE_FILE_NAME);
}
function getSkillLockPath() {
return join(getConfigDir(), "skills", "skill-lock.json");
}
/**
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
* best-effort: failure does not affect data sync results.
*/
function upsertSkillLock(name, entry) {
try {
let lock = { version: 1, skills: {} };
try {
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
lock = parsed;
}
} catch {
/* absent/corrupted → empty table */
}
lock.skills[name] = { ...lock.skills[name], ...entry };
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
} catch {
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
}
}
async function fetchJson(url, timeoutMs) {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function downloadBuffer(url) {
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
function isSafeEntryName(name) {
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
if (name.includes("\\") || name.includes("\0")) return false;
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
return !name.split("/").includes("..");
}
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
async function extractTarBr(tarBrBuffer, destDir) {
const extract = tar.extract();
extract.on("entry", (header, stream, next) => {
if (!isSafeEntryName(header.name)) {
// Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this
// error; silence the entry stream to avoid its companion error becoming unhandled
stream.on("error", () => {});
stream.resume();
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
return;
}
const filePath = join(destDir, header.name);
if (header.type === "directory") {
mkdirSync(filePath, { recursive: true });
stream.resume();
stream.on("end", next);
return;
}
mkdirSync(dirname(filePath), { recursive: true });
const ws = createWriteStream(filePath);
stream.pipe(ws);
ws.on("finish", next);
ws.on("error", next);
});
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
}
/**
* Recompute the publisher's deterministic content hash over an extracted directory
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
*/
function computeDirContentHash(dir) {
const relPaths = [];
const walk = (sub) => {
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
if (dirent.isDirectory()) walk(rel);
else if (dirent.isFile()) relPaths.push(rel);
}
};
walk("");
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
const hash = createHash("sha256");
for (const rel of relPaths) {
hash.update(rel);
hash.update(readFileSync(join(dir, rel)));
}
return `sha256:${hash.digest("hex")}`;
}
/** Atomic swap: tmpDir (same volume) → catalogDir. */
function atomicSwap(tmpDir, catalogDir) {
mkdirSync(dirname(catalogDir), { recursive: true });
const backup = `${catalogDir}.old-${Date.now()}`;
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
try {
renameSync(tmpDir, catalogDir);
} catch (err) {
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
throw err;
}
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
}
async function main() {
// 1. Download skills/index.json and get the wiki entry
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
const entry = index?.skills?.[WIKI_SKILL_NAME];
if (!entry?.contentHash)
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
// 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback
const assetName =
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
// 3. Extract to same-volume temp dir + integrity check + atomic swap
const catalogDir = getCatalogDir();
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
try {
mkdirSync(tmpDir, { recursive: true });
await extractTarBr(tarBuf, tmpDir);
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
// before touching the canonical dir
if (entry.contentHash.startsWith("sha256:")) {
const actualContentHash = computeDirContentHash(tmpDir);
if (actualContentHash !== entry.contentHash) {
throw new Error(
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
);
}
}
atomicSwap(tmpDir, catalogDir);
} catch (err) {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
throw err;
}
// 4. Write state
try {
writeFileSync(
getStatePath(),
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
);
} catch {
/* state write failure has no impact: first recommend will re-check */
}
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
upsertSkillLock(WIKI_SKILL_NAME, {
contentHash: entry.contentHash,
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
installedAt: new Date().toISOString(),
sourceType: "oss",
...(entry.description ? { description: entry.description } : {}),
});
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
}
main().catch((err) => {
// Unconditional pass-through: install-time network/permission issues should not block npm install;
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
);
// Force a success exit code so a download failure never fails `npm install`.
// eslint-disable-next-line unicorn/no-process-exit
process.exit(0);
});
+24
View File
@@ -84,11 +84,23 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
assetList,
assetGet,
assetFavorite,
assetUnfavorite,
assetDelete,
assetDownload,
assetStats,
assetStorage,
workspaceInit,
pluginInstall,
pluginLink,
pluginList,
pluginRemove,
skillAdd,
skillUpdate,
skillRemove,
skillList,
managedAgentInit,
managedAgentValidate,
managedAgentPlan,
@@ -198,11 +210,23 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"asset-center list": assetList,
"asset-center get": assetGet,
"asset-center favorite": assetFavorite,
"asset-center unfavorite": assetUnfavorite,
"asset-center delete": assetDelete,
"asset-center download": assetDownload,
"asset-center stats": assetStats,
"asset-center storage": assetStorage,
"workspace init": workspaceInit,
"plugin install": pluginInstall,
"plugin link": pluginLink,
"plugin list": pluginList,
"plugin remove": pluginRemove,
"skill add": skillAdd,
"skill update": skillUpdate,
"skill remove": skillRemove,
"skill list": skillList,
"managed-agent init": managedAgentInit,
"managed-agent validate": managedAgentValidate,
"managed-agent plan": managedAgentPlan,
@@ -0,0 +1,131 @@
import { describe, expect, test } from "vite-plus/test";
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
describe("e2e: asset-center", () => {
test("asset-center 分组展示子命令帮助且成功退出", async () => {
const { stdout, stderr, exitCode } = await runCli(["asset-center"]);
expect(exitCode, stderr).toBe(0);
const output = `${stdout}\n${stderr}`;
expect(output).toContain("list");
expect(output).toContain("storage");
expect(output).not.toContain("oss");
});
test("asset-center list --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--type");
expect(stderr).toContain("--recycle-bin");
expect(stderr).toContain("bl asset-center list");
});
test("asset-center get --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "get", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--asset-id");
});
test("asset-center favorite --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "favorite", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--id");
});
test("asset-center delete --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "delete", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--permanent");
});
test("asset-center download --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "download", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--id");
expect(stderr).not.toMatch(/(^|\s)--out(\s|$)/);
});
test("asset-center stats --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "stats", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--sync-failed");
});
test("asset-center storage --help 正常退出", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "storage", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("bl asset-center storage");
});
});
describe.skipIf(!isConsoleE2EReady())("e2e: asset-centerConsole", () => {
test("asset-center get 缺少 --asset-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "get", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--asset-id|Missing required argument/i);
});
test("asset-center favorite 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "favorite", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required argument/i);
});
test("asset-center download 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCli(["asset-center", "download", "--quiet"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required argument/i);
});
test("asset-center list --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"asset-center",
"list",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
api?: string;
data?: { deleteStatus?: string };
}>(stdout);
expect(data.api).toContain("listModelGeneratedAsset");
expect(data.data?.deleteStatus).toBe("NORMAL");
});
test("asset-center stats --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"asset-center",
"stats",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string }>(stdout);
expect(data.api).toContain("countModelGeneratedAsset");
});
test("asset-center storage --dry-run 输出请求参数", async () => {
const { stdout, stderr, exitCode } = await runCli([
"asset-center",
"storage",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ api?: string }>(stdout);
expect(data.api).toContain("getStorageQuota");
});
test("【console】asset-center list 真实调用或鉴权失败优雅退出", async () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID;
const args = ["asset-center", "list", "--output", "json", "--page-size", "1"];
if (workspaceId) args.push("--workspace-id", workspaceId);
const result = await runCli(args);
if (isConsoleAuthFailure(result)) return;
expect(result.exitCode, result.stderr).toBe(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.12.0",
"version": "1.14.1",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -6,6 +6,7 @@ import {
type GetModelsOptions,
getModels,
type IntentProfile,
maybeSyncWikiData,
type PipelineStep,
type RecommendedModel,
type RecommendResult,
@@ -248,6 +249,12 @@ export default defineCommand({
const { settings, flags } = ctx;
const userInput = flags.message;
const top = 3;
// Keep the local wiki catalog fresh: throttled (12h) version check against
// the remote manifest, silently replaces data when a newer version exists.
// Never throws — a sync failure must not block recommendation.
await maybeSyncWikiData();
// Default to JSON for structured output; render boxen cards only when the
// user explicitly asked for text output.
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
@@ -0,0 +1,434 @@
# 资产中心 CLI 命令树设计
> 本文档定义 `bl asset` 命令族的路径结构、help 层级、flags 概览与示例。
> 技术实现细节见 [DESIGN.md](./DESIGN.md)API 字段见 [api-doc.md](./api-doc.md)。
## 1. 命名原则
| 原则 | 说明 |
| ------------ | ------------------------------------------------------------------------- |
| 产品路径前缀 | `asset`(不用 `asset-center`,与 `deploy` / `dataset` 等产品域一致) |
| 层级深度 | 最多三级:`asset <group> <action>` |
| 子组条件 | 仅当子组下 ≥ 2 个 action 时使用子组(见 AGENTS.md |
| bin 前缀 | `usageArgs` / `exampleArgs` 不写 `bl`help 由 runtime 按路径补全 |
| 鉴权 | 全部 `auth: "console"`;自动可见 `--console-region` 等 CONSOLE_AUTH_FLAGS |
---
## 2. 命令树总览
```
bl asset
├── list # 分页查询资产列表
├── get <asset-id> # 查询单个资产详情
├── favorite # 收藏资产
├── unfavorite # 取消收藏
├── delete # 删除资产(默认软删到回收站)
├── restore # 从回收站恢复
├── download # 获取下载链接 / 可选落盘
├── stats # 资产数量统计
├── storage # 存储容量与配额
├── models # [P1] 模型列表(辅助筛选)
│ └── list
├── service # [P1/P2] 服务开通状态
│ ├── status
│ ├── enable # [P2]
│ └── disable # [P2]
```
---
## 3. 产品入口注册 Map
`packages/cli/src/commands.ts` 中预期注册camelCase export → kebab path
| Map Key | Export 名(建议) | Phase |
| ------------------------- | --------------------- | ----- |
| `"asset list"` | `assetList` | 1 |
| `"asset get"` | `assetGet` | 1 |
| `"asset favorite"` | `assetFavorite` | 1 |
| `"asset unfavorite"` | `assetUnfavorite` | 1 |
| `"asset delete"` | `assetDelete` | 1 |
| `"asset restore"` | `assetRestore` | 1 |
| `"asset download"` | `assetDownload` | 1 |
| `"asset stats"` | `assetStats` | 1 |
| `"asset storage"` | `assetStorage` | 1 |
| `"asset models list"` | `assetModelsList` | 2 |
| `"asset service status"` | `assetServiceStatus` | 2 |
| `"asset service enable"` | `assetServiceEnable` | 3 |
| `"asset service disable"` | `assetServiceDisable` | 3 |
---
## 4. Help 层级预览
### 4.1 顶层分组
```
$ bl asset
Asset management commands for Bailian Asset Center.
Commands:
list List model-generated assets
get Get asset details by ID
favorite Mark assets as favorites
unfavorite Remove assets from favorites
delete Delete assets (soft delete by default)
restore Restore soft-deleted assets
download Get asset download URLs
stats Count assets by type
storage View storage quota and usage
models Model configuration helpers
service Asset center service subscription
Run `bl asset <command> --help` for details.
```
---
## 5. 各命令规格
以下 `usageArgs` 为命令 metadata 中的值(不含 global flags。Global flags`--output``--dry-run``--quiet` 等)与 console flags`--workspace-id` 等)由 runtime 自动追加到 help。
---
### 5.1 Phase 1 命令
#### `bl asset list`
```
Description: List model-generated assets with filters and cursor pagination
Usage: bl asset list [flags]
Flags:
--type <type> Asset type: IMAGE, VIDEO, AUDIO
--model <name> Filter by model name
--keyword <text> Filter by asset name (substring)
--favorited Show only favorited assets
--recycle-bin Show soft-deleted assets (recycle bin)
--sync-status <status> OSS sync status filter
--begin-time <datetime> Filter by generate time start (ISO_LOCAL_DATE_TIME)
--end-time <datetime> Filter by generate time end
--include-download-url Include signed download URLs
--include-thumbnail Include thumbnail URLs
--thumbnail-width <px> Thumbnail width
--thumbnail-height <px> Thumbnail height
--page-size <n> Page size (default: 10, max: 100)
--next-token <token> Cursor for next page
--pre-token <token> Cursor for previous page
Examples:
bl asset list
bl asset list --type IMAGE --model qwen-image-3.0
bl asset list --favorited --page-size 20
bl asset list --recycle-bin
bl asset list --keyword landscape --output json
```
**PRD 映射:** #1 查看资产列表
---
#### `bl asset get <asset-id>`
```
Description: Get full details of a model-generated asset
Usage: bl asset get <asset-id> [flags]
Arguments:
<asset-id> Asset ID to query
Flags:
--asset-id <id> Asset ID (alternative to positional)
--include-download-url Include signed download URL
--include-thumbnail Include thumbnail URL
--thumbnail-width <px> Thumbnail width
--thumbnail-height <px> Thumbnail height
Examples:
bl asset get asset-001
bl asset get asset-001 --include-download-url --output json
```
**PRD 映射:** #2 查看资产详情
---
#### `bl asset favorite`
```
Description: Add assets to favorites
Usage: bl asset favorite --id <asset-id> [--id <asset-id>...]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
Examples:
bl asset favorite --id asset-001
bl asset favorite --id asset-001 --id asset-002
```
**PRD 映射:** #3 收藏
---
#### `bl asset unfavorite`
```
Description: Remove assets from favorites
Usage: bl asset unfavorite --id <asset-id> [--id <asset-id>...]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
Examples:
bl asset unfavorite --id asset-001
bl asset unfavorite --id asset-001 --id asset-002
```
**PRD 映射:** #3 取消收藏
---
#### `bl asset delete`
```
Description: Delete assets (soft delete to recycle bin by default)
Usage: bl asset delete --id <asset-id> [--id <asset-id>...] [flags]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
--permanent Permanently delete (cannot be restored)
Examples:
bl asset delete --id asset-001
bl asset delete --id asset-001 --id asset-002
bl asset delete --id asset-001 --permanent
```
**PRD 映射:** #4 删除资产、#5 批量删除
---
#### `bl asset restore`
```
Description: Restore soft-deleted assets from recycle bin
Usage: bl asset restore --id <asset-id> [--id <asset-id>...]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
Examples:
bl asset restore --id asset-001
bl asset restore --id asset-001 --id asset-002
```
**PRD 映射:** 补充能力(配合回收站)
---
#### `bl asset download`
```
Description: Get signed download URLs for assets
Usage: bl asset download --id <asset-id> [--id <asset-id>...] [--out <path>]
Flags:
--id <asset-id> Asset ID (repeatable, max 100, required)
--out <path> Save file to path (only when exactly one --id)
Examples:
bl asset download --id asset-001
bl asset download --id asset-001 --out ./image.png
bl asset download --id asset-001 --id asset-002 --output json
```
**PRD 映射:** #6 下载资产
---
#### `bl asset stats`
```
Description: Count model-generated assets by type
Usage: bl asset stats [flags]
Flags:
--type <type> Filter by asset type
--model <name> Filter by model name
--keyword <text> Filter by asset name
--favorited Count only favorited assets
--recycle-bin Count soft-deleted assets
--sync-failed Also count assets with failed OSS sync
--begin-time <datetime> Filter by generate time start
--end-time <datetime> Filter by generate time end
Examples:
bl asset stats
bl asset stats --sync-failed
bl asset stats --type IMAGE --output json
```
**PRD 映射:** #7 查看资产统计
**text 输出示例:**
```
Total: 200
Image: 150
Video: 30
Audio: 20
Sync failed: 5 # 仅 --sync-failed 时出现
```
---
#### `bl asset storage`
```
Description: View storage quota, usage, and overage pricing
Usage: bl asset storage [flags]
Examples:
bl asset storage
bl asset storage --output json
```
**PRD 映射:** #14 查看容量信息
**text 输出示例:**
```
Used: 1.2 GB
Free quota: 5.0 GB
Overage: ¥0.12/GB/month
```
---
### 5.3 Phase 2/3 可选命令
#### `bl asset models list`
```
Description: List managed models grouped by asset type
Usage: bl asset models list
Examples:
bl asset models list --output json
```
用途:配合 `bl asset list --model` 时查阅可用 modelId。
---
#### `bl asset service status`
```
Description: Check whether asset center service is enabled
Usage: bl asset service status
Examples:
bl asset service status
```
---
---
## 6. PRD 覆盖矩阵
| PRD # | 功能 | CLI 命令 | Phase | 状态 |
| ----- | ------------- | ------------------------------- | ----- | ---------------------------------- |
| 1 | 查看资产列表 | `asset list` | 1 | ✅ 可开发 |
| 2 | 查看资产详情 | `asset get` | 1 | ✅ 可开发 |
| 3 | 收藏/取消收藏 | `asset favorite` / `unfavorite` | 1 | ✅ 可开发 |
| 4 | 删除资产 | `asset delete` | 1 | ✅ 可开发 |
| 5 | 批量删除 | `asset delete`(多 `--id` | 1 | ✅ 可开发 |
| 6 | 下载资产 | `asset download` | 1 | ✅ 可开发 |
| 7 | 查看资产统计 | `asset stats` | 1 | ✅ 可开发(转存失败用 workaround |
| 14 | 查看容量信息 | `asset storage` | 1 | ✅ 可开发 |
---
## 7. 典型工作流
### 7.1 首次使用
```bash
bl auth login --console
bl config set workspace_id ws-xxxxx
bl asset service status # 可选:确认已开通
bl asset storage # 查看容量
```
### 7.2 浏览与筛选
```bash
bl asset list
bl asset list --type IMAGE --model qwen-image-3.0 --keyword landscape
bl asset list --favorited
bl asset list --recycle-bin
bl asset get asset-001 --include-download-url
bl asset stats
bl asset stats --sync-failed
```
### 7.3 资产管理
```bash
bl asset favorite --id asset-001
bl asset unfavorite --id asset-001
bl asset delete --id asset-001
bl asset delete --id asset-001 --id asset-002
bl asset restore --id asset-001
bl asset download --id asset-001 --out ./image.png
```
### 7.5 脚本翻页JSON
```bash
# 第一页
bl asset list --page-size 50 --output json
# 后续页(使用响应中的 nextToken
bl asset list --page-size 50 --next-token 1000 --output json
```
---
## 8. 与现有命令的风格对齐
| 参考命令 | 对齐点 |
| ------------------------------ | -------------------------------------------------- |
| `bl app list` | console gateway 调用、dry-run 输出 `{ api, data }` |
| `bl dataset list` | text 表格 + json items 结构 |
| `bl deploy list/get/create` | 产品域子命令命名、多级 path |
| `bl memory profile get/create` | 三级 path 子组 |
| `bl quota list` | `zeldaHttp.*` API 名、响应 extract |
| `bl video download` | `--out` 落盘 |
| `bl usage stats` | `requireWorkspaceId`、console E2E 模式 |
---
## 9. 变更记录
| 日期 | 版本 | 说明 |
| ---------- | ---- | ---------------------------------------------- |
| 2026-07-09 | 0.1 | 初稿命令树、PRD 映射、分 Phase 规格 |
| 2026-08-07 | 0.2 | 取消 OSS 转存命令(`oss *` / `transfer list` |
@@ -0,0 +1,408 @@
# 资产中心 CLI 设计文档
> 本文档描述 `bl asset` 命令族的技术设计方案,供开发、评审与联调使用。
> 接口字段细节见同目录 [api-doc.md](./api-doc.md);命令路径与 help 结构见 [COMMAND-TREE.md](./COMMAND-TREE.md)。
## 1. 背景与目标
### 1.1 背景
百炼资产中心Asset Center提供模型生成资产的存储、检索、收藏、删除与容量管理能力。产品 PRD 要求 CLI 覆盖以下模块:
| 模块 | PRD 能力 |
| -------- | ----------------------------------------------- |
| 资产管理 | 列表、详情、收藏/取消收藏、删除、批量删除、下载 |
| 资产统计 | 总量、按类型统计 |
| 容量 | 已用容量、免费额度、超额单价 |
后端接口通过 **Zelda HTTP 网关** 暴露Base Path 为 `/zelda/api/v1/bailian/asset`,详见 [api-doc.md](./api-doc.md)。
### 1.2 目标
-`packages/commands` 实现可复用命令库,由 `packages/cli/src/commands.ts` 注册为 `bl asset ...` 产品路径
- 遵循 monorepo 分层约定:`commands` 不写产品 bin 前缀Console Gateway 命令统一 `auth: "console"`
- 服务端错误原样透传CLI 仅对参数校验、缺凭证、网络失败等内部错误发出语义化 `BailianError`
- 支持 `--dry-run``--output json`、text 表格输出等现有 CLI 惯例
### 1.3 非目标
- 不在 `rag` 入口暴露(首期与 `deploy` / `finetune` 一致,仅 `bl`
- 不暴露 `sendMqMessage` 等内部 MQ 接口
- 不在 `core` / `runtime` 层硬编码 `bl` 命令名或控制台 URL
---
## 2. PRD → API → CLI 映射
### 2.1 资产管理
| PRD # | 能力 | CLI 命令 | API Action | 备注 |
| ----- | ------------- | ------------------------------------------- | --------------------------------------------- | ------------------------------------------ |
| 1 | 查看资产列表 | `bl asset list` | `listModelGeneratedAsset` | 游标分页;支持类型/模型/关键词/收藏/回收站 |
| 2 | 查看资产详情 | `bl asset get <asset-id>` | `getModelGeneratedAsset` | positional 或 `--asset-id` |
| 3 | 收藏/取消收藏 | `bl asset favorite` / `bl asset unfavorite` | `batchFavoriteAsset` / `batchUnfavoriteAsset` | 单 ID 也走 batch长度 1 |
| 4 | 删除资产 | `bl asset delete` | `batchDeleteAsset` | 默认 `SOFT_DELETE`(移入回收站) |
| 5 | 批量删除 | `bl asset delete` | `batchDeleteAsset` | `--id` 可重复,最多 100 |
| 6 | 下载资产 | `bl asset download` | `batchGetAssetDownloadUrl` | 默认输出 URL单资产可选 `--out` 落盘 |
**建议补充API 已有、PRD 未写):**
| 能力 | CLI 命令 | API Action |
| ------------ | ------------------ | ------------------- |
| 从回收站恢复 | `bl asset restore` | `batchRestoreAsset` |
### 2.2 资产统计
| PRD # | 能力 | CLI 命令 | API Action | 备注 |
| ----- | ------------ | ---------------- | -------------------------- | --------------------------------------- |
| 7 | 查看资产统计 | `bl asset stats` | `countModelGeneratedAsset` | 输出 total / image / video / audio 计数 |
**转存失败数PRD 子项):**
- API 支持 `syncOssDataStatus=SYNC_FAILED` 筛选,但无独立 `failureCount` 字段
- **Phase 1 方案**`bl asset stats --sync-failed` 额外发起一次 count 查询,输出 `sync_failed_count`
- **Phase 3 备选**:等后端在 stats 响应中增加专用字段后收敛
### 2.4 容量
| PRD # | 能力 | CLI 命令 | API Action |
| ----- | ------------ | ------------------ | ----------------- |
| 14 | 查看容量信息 | `bl asset storage` | `getStorageQuota` |
### 2.5 可选扩展API 有、PRD 未列)
| CLI 命令 | API Action | 优先级 |
| ------------------------------------- | ------------------------------- | -------------------- |
| `bl asset service status` | `checkAssetServiceSubscription` | P1 |
| `bl asset service enable` / `disable` | `subscribeAssetService` | P2 |
| `bl asset models list` | `listModels` | P1配合 list 筛选) |
---
## 3. 架构与分层
### 3.1 在 monorepo 中的位置
```
packages/commands/src/commands/asset-center/*.ts ← 命令实现(本目录)
↓ export
packages/commands/src/index.ts
↓ import + map key
packages/cli/src/commands.ts ← "asset list": assetList, ...
packages/runtime (createCli / authStage / registry)
```
约定:
- 实现文件按能力组织在本目录
- `usageArgs` / `exampleArgs` 不含 `bl` 前缀
- 所有 asset 命令 `auth: "console"`;不重复声明 `CONSOLE_AUTH_FLAGS`runtime 自动注入)
### 3.2 目录结构
```
asset-center/
├── api-doc.md # 后端 API 文档(已有)
├── DESIGN.md # 本文档
├── COMMAND-TREE.md # 命令树与 help 结构
├── types.ts # TypeScript 类型ModelGeneratedAssetItem 等)
├── utils.ts # 公共请求构建、API 调用、响应解析
├── list.ts
├── get.ts
├── favorite.ts
├── unfavorite.ts
├── delete.ts
├── download.ts
├── stats.ts
└── storage.ts
```
### 3.3 共享层 `utils.ts`
参考 `token-plan/utils.ts``usage/stats.ts``requireWorkspaceId` 模式。
#### 3.3.1 API 名称约定
`quota/list.ts``zeldaHttp.dashscopeModel./zelda/api/v1/...` 类似,资产中心预期为:
```typescript
const ASSET_SERVICE = "bailianAsset"; // ⚠️ 编码前需 spike 确认
const ASSET_BASE = "/zelda/api/v1/bailian/asset";
function assetApi(action: string): string {
return `zeldaHttp.${ASSET_SERVICE}.${ASSET_BASE}/${action}`;
}
```
编码第一步用 `bl console call --api <name> --data '{...}'` 验证实际注册名。
#### 3.3.2 公共请求体
所有接口继承 `AssetHttpBaseRequest`(见 api-doc §公共请求参数):
| 字段 | CLI 来源 | 状态 |
| ---------------- | --------------------------------------------------------- | ---------- |
| `workspace` | `settings.workspaceId``--workspace-id` / env / config | ✅ 已有 |
| `tenantId` | 待定 | ⚠️ 需确认 |
| `mainAccountUid` | 待定 | ⚠️ 需确认 |
| `apiSource` | 固定 `"CLI"` | 实现时写入 |
| `aliYunUid` 等 | 网关 session 注入或省略 | 待确认 |
`requireWorkspaceId(settings, binName)` 在缺少 workspace 时抛出 `BailianError(GENERAL)`hint 指向 `bl workspace list`
#### 3.3.3 调用封装
```typescript
async function callAssetApi<T>(
ctx: CommandRunContext,
action: string,
body: Record<string, unknown>,
): Promise<T> {
const payload = { ...buildBaseRequest(ctx), ...body };
const raw = await ctx.client.console(assetApi(action), payload);
return extractAssetResponse<T>(raw);
}
```
#### 3.3.4 响应解析
Console Gateway 响应可能存在多层嵌套(参考 `quota/list.ts``extractResponseData`
1. 剥 gateway 外层:`data``DataV2``data` → ...
2. 到达业务 `Result<T>``{ success, code, message, data }`
3.`success === false`:抛 `BailianError(GENERAL, message)`**不翻译、不替换** message
4. 成功时返回 `data` 字段
---
## 4. 命令实现规范
### 4.1 通用模式
每个命令文件遵循:
```typescript
export default defineCommand({
description: "...",
auth: "console",
usageArgs: "...",
flags: { ... },
exampleArgs: ["...", "--output json"],
validate(ctx) { /* 跨 flag 条件校验 */ },
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
emitResult({ api: assetApi("..."), data: { ... } }, format);
return;
}
const data = await callAssetApi(ctx, "actionName", { ... });
// text 表格 或 emitResult(json)
},
});
```
参考实现:`app/list.ts`console + dry-run`dataset/list.ts`(表格输出)、`video/download.ts`(落盘)。
### 4.2 分页模型(`asset list`
**与 `app list` 不同**:资产列表使用 **id 游标分页**,不是 page/pageSize 页码模式。
| Flag | API 字段 | 说明 |
| -------------- | ----------- | -------------------------- |
| `--page-size` | `pageSize` | 默认 10最大 100 |
| `--next-token` | `nextToken` | 下一页游标(来自上次响应) |
| `--pre-token` | `preToken` | 上一页游标 |
JSON 输出保留 `nextToken` / `preToken` / `hasNext` / `hasPre`,便于脚本翻页。
### 4.3 批量 ID 传参
批量操作favorite / unfavorite / delete / restore / download统一
```typescript
id: {
type: "array",
valueHint: "<asset-id>",
description: "Asset ID(s) to operate on (repeatable, max 100)",
required: true,
}
```
CLI 用法:`--id asset-001 --id asset-002` 或多次重复。实现时在 `validate` 中校验 `ids.length <= 100`
### 4.4 输出格式
| 命令 | text 默认 | json |
| ---------- | -------------------------------------------------------------- | ----------------------------------------- |
| `list` | 表格assetId / type / name / model / favorited / generateTime | items + pagination |
| `get` | 关键字段摘要 | 完整 item |
| `stats` | 数字摘要 | `{ total_count, image_count, ... }` |
| `storage` | 人类可读字节 + 单价 | 原始 quota 字段 |
| 写操作 | 一行确认affectedCount | `{ success, affected_count }` |
| `download` | URL 列表或 saved 路径 | `{ items: [{ asset_id, download_url }] }` |
使用 `formatTable``dataset/list.ts`)、`formatBytes``video/download.ts`)、`emitResult` / `emitBare`
### 4.5 条件校验(`validate`
| 命令 | 规则 |
| ---------- | --------------------------------------------------------- |
| `delete` | `--permanent` 映射 `PERMANENT_DELETE`;默认 `SOFT_DELETE` |
| 所有 batch | `assetIdList.length <= 100` |
---
## 5. 关键命令 Flag 详设
### 5.1 `bl asset list`
| Flag | 类型 | API 映射 | 说明 |
| ------------------------ | ---------------- | ---------------------------- | ------------------------------------------------------------ |
| `--type` | string (choices) | `assetType` | `IMAGE` / `VIDEO` / `AUDIO` |
| `--model` | string | `modelName` | PRD「按模型筛选」 |
| `--keyword` | string | `assetName` | PRD「关键词」是否同时搜 description 待产品确认 |
| `--favorited` | switch | `favorited: true` | 仅看收藏 |
| `--recycle-bin` | switch | `deleteStatus: SOFT_DELETED` | 仅看回收站 |
| `--sync-status` | string (choices) | `syncOssDataStatus` | `NOT_SYNCED` / `IN_SYNCING` / `SYNC_SUCCESS` / `SYNC_FAILED` |
| `--begin-time` | string | `beginTime` | ISO_LOCAL_DATE_TIME |
| `--end-time` | string | `endTime` | ISO_LOCAL_DATE_TIME |
| `--include-download-url` | switch | `includeDownloadUrl` | |
| `--include-thumbnail` | switch | `includeThumbnail` | |
| `--thumbnail-width` | number | `thumbnailWidth` | 配合 thumbnail |
| `--thumbnail-height` | number | `thumbnailHeight` | 配合 thumbnail |
| `--page-size` | number | `pageSize` | |
| `--next-token` | number | `nextToken` | |
| `--pre-token` | number | `preToken` | |
### 5.2 `bl asset get`
| 参数/Flag | 说明 |
| ------------------------------------------ | --------------------------------------- |
| `<asset-id>` | positionalprimary |
| `--asset-id` | 与 positional 二选一positional 优先) |
| `--include-download-url` | |
| `--include-thumbnail` | |
| `--thumbnail-width` / `--thumbnail-height` | |
### 5.3 `bl asset delete`
| Flag | 说明 |
| ------------- | --------------------------------------------------------- |
| `--id` | array, required, max 100 |
| `--permanent` | switch → `deleteType: PERMANENT_DELETE`;默认 SOFT_DELETE |
### 5.4 `bl asset download`
| Flag | 说明 |
| ------- | ----------------------------------------------------- |
| `--id` | array, required |
| `--out` | 仅当 `--id` 恰好 1 个时有效;调用 `downloadFile` 落盘 |
### 5.5 `bl asset stats`
| Flag | 说明 |
| ------------------------------------- | ---------------------------------------------- |
| (无 filter | 默认 `deleteStatus: NORMAL` |
| `--recycle-bin` | `deleteStatus: SOFT_DELETED` |
| `--sync-failed` | 额外查询 `syncOssDataStatus: SYNC_FAILED` 计数 |
| `--type` / `--model` / `--keyword` 等 | 与 list 相同筛选维度(可选) |
---
## 6. 风险与待确认项
### 6.1 P0 — 编码前必须对齐
| # | 问题 | 影响 | 建议动作 |
| --- | ------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------- |
| 1 | Console API 注册名(`zeldaHttp.{service}./zelda/api/v1/bailian/asset/*` | 无法调用 | `bl console call` spike与后端确认 service 名 |
| 2 | `tenantId` / `mainAccountUid` 由谁填充 | 所有接口必填 | 确认网关是否从 session 自动注入;否则扩展 config 或新增解析 API |
### 6.2 P1 — 产品设计
| # | 问题 | 建议默认 |
| --- | -------------------- | ----------------------------------------------- |
| 4 | 关键词搜索范围 | 仅 `assetName`;后续可加 `--search-description` |
| 5 | PRD 只提 image/video | CLI 暴露 IMAGE/VIDEO/AUDIO与 API 一致) |
| 6 | 下载行为 | 默认输出 URL单 ID + `--out` 落盘 |
| 7 | 永久删除 | 提供 `--permanent`help 注明不可恢复 |
| 8 | 服务未开通 | 不预检查;失败时透传服务端 message |
| 9 | 收藏命令形态 | 两个命令 `favorite` / `unfavorite`(语义清晰) |
---
## 7. 错误处理
遵循 [AGENTS.md](../../../../../../AGENTS.md) 错误边界:
| 场景 | 处理 |
| --------------------------------- | --------------------------------------------- |
| 缺 `--workspace-id` | `BailianError(GENERAL)` + hint |
| 缺 console token | authStage 抛 `BailianError(AUTH)` |
| flag 校验失败 | `UsageError` (exit 2) |
| HTTP 4xx/5xx / 业务 success=false | `BailianError(GENERAL)`message **原样透传** |
| batch ID > 100 | `UsageError` |
Console 未登录参考 `mcp/list.ts`:检测 `BailianGateway.Login.NotLogined` 时 hint 指向 `bl auth login --console`
---
## 8. 测试策略
新建 `packages/cli/tests/e2e/asset.e2e.test.ts`,遵循 [cli-e2e-tests.md](../../../../../../docs/agents/cli-e2e-tests.md)。
### 8.1 不 skip 层
- `bl asset` 分组 help
- 各子命令 `--help`
- 缺参 → exit 2
### 8.2 Console skip 层(`isConsoleE2EReady()`
- 各命令 `--dry-run` 输出 api + data
- 真实 `asset list` / `asset storage` 集成(需已开通资产中心的工作空间)
环境:`BAILIAN_E2E=1` + console `access_token` + `BAILIAN_WORKSPACE_ID`
---
## 9. 注册与文档变更清单
| 文件 | 变更 |
| -------------------------------------------------- | ------------------- |
| `packages/commands/src/commands/asset-center/*.ts` | 新建 |
| `packages/commands/src/index.ts` | export |
| `packages/cli/src/commands.ts` | 注册 map |
| `packages/cli/tests/e2e/asset.e2e.test.ts` | 新建 |
| `skills/bailian-cli/reference/` | pre-commit 自动生成 |
| `README.md` / `README.zh.md` | 发版前补充命令一览 |
---
## 10. 分期实施
### Phase 1 — 核心资产P0
```
asset list | get | favorite | unfavorite | delete | restore | download | stats | storage
```
**前置:** §6.1 #1 #2 确认。
### Phase 2+ — 可选扩展
```
asset models list | service status | service enable/disable
```
> OSS 转存相关命令(`asset-center oss *` / `transfer list`)已取消,不再排期。
## 11. 参考
- 命令注册:[docs/agents/command-add-remove.md](../../../../../../docs/agents/command-add-remove.md)
- E2E 规范:[docs/agents/cli-e2e-tests.md](../../../../../../docs/agents/cli-e2e-tests.md)
- Console 命令样例:`packages/commands/src/commands/app/list.ts`
- 游标/表格:`packages/commands/src/commands/quota/list.ts`
- workspace 必填:`packages/commands/src/commands/usage/stats.ts`
- 文件落盘:`packages/commands/src/commands/video/download.ts`
@@ -0,0 +1,35 @@
# Asset Center 命令测试报告 — Phase 2
- **测试时间**: 2026-07-10 09:07:09 (UTC)
- **Workspace**: `llm-0xvms4kqhbqjlg8s`
- **测试 IMAGE**: `asset_98175cbf83294f7b8ada86657623dcf3`
- **测试 VIDEO**: `asset_df026105d2274ff9b8c824058fa23d60`
- **策略**: 可逆写操作favorite/unfavorite 往返download 到 /tmp 后删除;其余只读
- **汇总**: 16 通过 / 0 失败 / 16 总计
> Phase 1 报告见同目录 [TEST-REPORT.md](./TEST-REPORT.md)24 项 dry-run + 只读基础验证)
## Phase 2 测试结果
| # | 分类 | 命令 | 模式 | 状态 | Exit | 耗时 | 结果摘要 |
| --- | ---- | ------------------------------------------ | -------- | ------- | ---- | ------- | -------------------------------------------------------------------------------------------------------- |
| 1 | 下载 | `asset-center download (IMAGE)` | 真实调用 | ✅ PASS | 0 | 20045ms | saved /tmp/asset-center-test-asset_98175cbf83294f7b8ada86657623dcf3.png (1449847 bytes, reported 1.4 MB) |
| 2 | 查询 | `asset-center get --include-download-url` | 真实调用 | ✅ PASS | 0 | 21226ms | download_url present |
| 3 | 查询 | `asset-center list --include-download-url` | 真实调用 | ✅ PASS | 0 | 19057ms | items contain download_url |
| 4 | 查询 | `asset-center list --next-token` | 真实调用 | ✅ PASS | 0 | 19584ms | page2=3 items, overlap=0, has_pre=true |
| 5 | 统计 | `asset-center stats --type IMAGE` | 真实调用 | ✅ PASS | 0 | 18830ms | image=7, total=7 |
| 6 | 统计 | `asset-center stats --sync-failed` | 真实调用 | ✅ PASS | 0 | 19084ms | total=27, sync_failed=0 |
| 7 | 查询 | `asset-center list --recycle-bin` | 真实调用 | ✅ PASS | 0 | 19314ms | 0 soft-deleted item(s) |
| 8 | 输出 | `asset-center list (text)` | 真实调用 | ✅ PASS | 0 | 17840ms | 4 lines table output |
| 9 | 收藏 | `asset-center favorite (真实)` | 真实调用 | ✅ PASS | 0 | 22082ms | affected=1 |
| 10 | 收藏 | `get 验证 favorited=true` | 真实调用 | ✅ PASS | 0 | 18865ms | favorited=true ✓ |
| 11 | 查询 | `list --favorited 含测试资产` | 真实调用 | ✅ PASS | 0 | 22120ms | found in favorited list |
| 12 | 收藏 | `asset-center unfavorite (真实)` | 真实调用 | ✅ PASS | 0 | 22133ms | affected=1 |
| 13 | 收藏 | `get 验证 favorited=false (恢复)` | 真实调用 | ✅ PASS | 0 | 27797ms | favorited=false ✓ |
| 14 | 收藏 | `favorite 批量 (--id x2)` | 真实调用 | ✅ PASS | 0 | 22947ms | affected=2 |
| 15 | 收藏 | `unfavorite 批量 (--id x2)` | 真实调用 | ✅ PASS | 0 | 15385ms | affected=2 |
| 16 | 边界 | `get 不存在的 asset-id` | 真实调用 | ✅ PASS | 1 | 12207ms | exit 1, 服务端错误原样透传: "资产不存在" |
## 边界行为说明
查询不存在的 `asset-id` 时,服务端返回业务错误 **「资产不存在」**CLI 按约定 **原样透传**exit code 1不会替换为本地文案。这与 AGENTS.md 错误处理边界一致。
@@ -0,0 +1,36 @@
# Asset Center 命令测试报告
- **测试时间**: 2026-07-10 08:41:06 (UTC)
- **Workspace**: `llm-0xvms4kqhbqjlg8s`
- **样本 Asset ID**: `asset_df026105d2274ff9b8c824058fa23d60`
- **策略**: 只读命令真实调用;写操作/下载一律 `--dry-run`
- **汇总**: 24 通过 / 0 失败 / 24 总计
## 测试结果
| # | 分类 | 命令 | 模式 | 状态 | Exit | 耗时 | 结果摘要 |
| --- | ---- | ---------------------------------- | -------- | ------- | ---- | ------- | -------------------------------------------------------------- |
| 1 | 查询 | `asset-center list` | 真实调用 | ✅ PASS | 0 | 18962ms | 3 item(s), next=94 |
| 2 | 查询 | `asset-center list --type IMAGE` | 真实调用 | ✅ PASS | 0 | 16411ms | 2 item(s), next=90 |
| 3 | 查询 | `asset-center get` | 真实调用 | ✅ PASS | 0 | 16541ms | {gmtModified, aliyunUid, generateTime, aliyunMainId} |
| 4 | 统计 | `asset-center stats` | 真实调用 | ✅ PASS | 0 | 14226ms | total=27 |
| 5 | 统计 | `asset-center storage` | 真实调用 | ✅ PASS | 0 | 18622ms | {used_storage_size, free_storage_quota, extra_storage_price} |
| 9 | 查询 | `asset-center list --dry-run` | dry-run | ✅ PASS | 0 | 23855ms | dry-run → /zelda/api/v1/bailian/asset/listModelGeneratedAsset |
| 10 | 查询 | `asset-center get --dry-run` | dry-run | ✅ PASS | 0 | 17398ms | dry-run → /zelda/api/v1/bailian/asset/getModelGeneratedAsset |
| 11 | 收藏 | `asset-center favorite` | dry-run | ✅ PASS | 0 | 16471ms | dry-run → /zelda/api/v1/bailian/asset/batchFavoriteAsset |
| 12 | 收藏 | `asset-center unfavorite` | dry-run | ✅ PASS | 0 | 19724ms | dry-run → /zelda/api/v1/bailian/asset/batchUnfavoriteAsset |
| 13 | 删除 | `asset-center delete` | dry-run | ✅ PASS | 0 | 20612ms | dry-run → /zelda/api/v1/bailian/asset/batchDeleteAsset |
| 14 | 下载 | `asset-center download` | dry-run | ✅ PASS | 0 | 20348ms | dry-run → /zelda/api/v1/bailian/asset/batchGetAssetDownloadUrl |
| 15 | 统计 | `asset-center stats --dry-run` | dry-run | ✅ PASS | 0 | 14898ms | dry-run → /zelda/api/v1/bailian/asset/countModelGeneratedAsset |
| 16 | 统计 | `asset-center storage --dry-run` | dry-run | ✅ PASS | 0 | 14305ms | dry-run → /zelda/api/v1/bailian/asset/getStorageQuota |
| 21 | 校验 | `asset-center get (缺 asset-id)` | 参数校验 | ✅ PASS | 2 | 15202ms | Error: Missing required flag: --asset-id |
| 22 | 校验 | `asset-center favorite (缺 --id)` | 参数校验 | ✅ PASS | 2 | 16697ms | Error: Missing required flag: --id |
| 23 | 校验 | `asset-center download (缺 --out)` | 参数校验 | ✅ PASS | 2 | 18803ms | Error: Missing required flag: --out |
## 模式说明
| 模式 | 说明 |
| -------- | -------------------------------------------------- |
| 真实调用 | 只读 API不修改数据 |
| dry-run | 输出 `{ api, data, gateway }` 请求体,不发起写操作 |
| 参数校验 | 预期 exit code 2用法错误 |
@@ -0,0 +1,624 @@
# BailianAssetZeldaHttpService API 文档
通过 Zelda 网关调用 bailian-asset HTTP 接口文档。
## 基础信息
- **Base Path**: `/zelda/api/v1/bailian/asset`
- **Method**: POST
- **Content-Type**: `application/json`
- **Accept**: `application/json`
## 统一响应格式
所有接口返回 `Result<T>` 结构:
```json
{
"requestId": "string",
"success": true,
"code": "string",
"message": "string",
"data": { ... }
}
```
| 字段 | 类型 | 说明 |
| --------- | ------- | ---------------------- |
| requestId | String | 请求唯一ID |
| success | Boolean | 是否成功 |
| code | String | 错误码(失败时返回) |
| message | String | 错误信息(失败时返回) |
| data | Object | 业务数据(成功时返回) |
## 公共请求参数(基类字段)
所有接口请求体均继承自 `AssetHttpBaseRequest`,包含以下公共字段:
| 字段 | 类型 | 必填 | 说明 |
| -------------- | ------ | ---- | ---------------------------------------------- |
| requestId | String | 否 | 请求唯一ID |
| apiSource | String | 否 | 调用入口渠道,如 OpenAPI、CloudSDK |
| tenantId | String | 是 | 内部租户ID |
| workspace | String | 是 | 业务空间ID |
| aliYunUid | String | 否 | 阿里云子账号ID |
| mainAccountUid | String | 是 | 阿里云主账号ID |
| callerType | String | 否 | 账号类型partner/customer/sub/AssumedRoleUser |
| callerParentId | Long | 否 | 调用者所属主账号ID |
| accessKeyId | String | 否 | STS认证用户AccessKeyId |
| securityToken | String | 否 | STS认证扮演者的STS Token |
---
## 1. 开通/关闭资产中心服务
**POST** `/zelda/api/v1/bailian/asset/subscribeAssetService`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------ | ------------ | ---- | --------------------------------------- |
| action | String(Enum) | 是 | 操作类型:`ENABLE`-开通,`DISABLE`-关闭 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------ | ------------ | ------------ |
| status | String(Enum) | 当前服务状态 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"action": "ENABLE"
}
```
---
## 8. 批量收藏资产
**POST** `/zelda/api/v1/bailian/asset/batchFavoriteAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ---------------------------------- |
| assetIdList | List<String> | 是 | 待收藏的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | -------------------- |
| success | Boolean | 是否收藏成功 |
| affectedCount | Integer | 实际被收藏的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002", "asset-003"]
}
```
---
## 9. 批量取消收藏资产
**POST** `/zelda/api/v1/bailian/asset/batchUnfavoriteAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | -------------------------------------- |
| assetIdList | List<String> | 是 | 待取消收藏的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | ------------------------ |
| success | Boolean | 是否取消收藏成功 |
| affectedCount | Integer | 实际被取消收藏的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002"]
}
```
---
## 10. 批量删除资产
**POST** `/zelda/api/v1/bailian/asset/batchDeleteAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ----------------------------------------------------------- |
| assetIdList | List<String> | 是 | 待删除的资产ID列表长度不超过 100 |
| deleteType | String(Enum) | 是 | 删除类型:`SOFT_DELETE`-软删除,`PERMANENT_DELETE`-彻底删除 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | -------------------- |
| success | Boolean | 是否删除成功 |
| affectedCount | Integer | 实际被删除的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002"],
"deleteType": "SOFT_DELETE"
}
```
---
## 11. 批量恢复软删除资产
**POST** `/zelda/api/v1/bailian/asset/batchRestoreAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ---------------------------------- |
| assetIdList | List<String> | 是 | 待恢复的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ------------- | ------- | -------------------- |
| success | Boolean | 是否恢复成功 |
| affectedCount | Integer | 实际被恢复的资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002"]
}
```
---
## 12. 分页查询模型生成资产
**POST** `/zelda/api/v1/bailian/asset/listModelGeneratedAsset`
采用 id 游标分页,默认 pageSize=10最大 100。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------------- | ------------ | ---- | ---------------------------------------------------------------------------------- |
| preToken | Long | 否 | 向前翻页游标 |
| nextToken | Long | 否 | 向后翻页游标(查询下一页时传入上一次响应的 nextToken |
| pageSize | Integer | 否 | 每页大小,默认 10最大 100 |
| includeDownloadUrl | Boolean | 否 | 是否返回文件下载链接,默认 false |
| includeThumbnail | Boolean | 否 | 是否返回资产缩放图 URL默认 false |
| thumbnailWidth | Integer | 否 | 缩放图宽度像素includeThumbnail=true 时生效 |
| thumbnailHeight | Integer | 否 | 缩放图高度像素includeThumbnail=true 时生效 |
| softDeleteTimeOrder | String(Enum) | 否 | 软删除时间排序方式:`ASC`-正序,`DESC`-倒序;仅在 deleteStatus=SOFT_DELETED 时有效 |
| assetType | String(Enum) | 否 | 资产类型:`IMAGE`-图片,`VIDEO`-视频,`AUDIO`-音频 |
| favorited | Boolean | 否 | 是否被收藏 |
| assetName | String | 否 | 资产名称(子串模糊匹配) |
| assetDescription | String | 否 | 资产描述(子串模糊匹配) |
| trusted | Boolean | 否 | 是否可信 |
| modelType | String | 否 | 生成资产的模型类型 |
| modelName | String | 否 | 生成资产的模型型号 |
| syncWhiteListStatus | String(Enum) | 否 | 同步白名单状态:`NOT_SYNCED` / `SYNC_SUCCESS` / `SYNC_FAILED` |
| syncOssDataStatus | String(Enum) | 否 | 同步OSS数据状态`NOT_SYNCED` / `IN_SYNCING` / `SYNC_SUCCESS` / `SYNC_FAILED` |
| deleteStatus | String(Enum) | 否 | 删除状态:`NORMAL` / `SOFT_DELETED` / `PERMANENTLY_DELETED` |
| beginTime | String | 否 | 资产生成时间起始(含),格式 ISO_LOCAL_DATE_TIME`2023-10-25T14:30:00` |
| endTime | String | 否 | 资产生成时间截止(含),格式 ISO_LOCAL_DATE_TIME`2023-10-25T14:30:00` |
### 响应 data
| 字段 | 类型 | 说明 |
| --------- | ----------------------------- | ------------ |
| dataList | List<ModelGeneratedAssetItem> | 资产列表 |
| preToken | Long | 前一页游标 |
| nextToken | Long | 下一页游标 |
| hasNext | Boolean | 是否有下一页 |
| hasPre | Boolean | 是否有前一页 |
**ModelGeneratedAssetItem 结构:**
| 字段 | 类型 | 说明 |
| ------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| id | Long | 主键 ID分页游标 token |
| gmtCreate | Date | 创建时间 |
| gmtModified | Date | 修改时间 |
| workspaceId | String | 工作空间ID |
| tenantId | String | 租户ID |
| aliyunUid | String | 阿里云子账号ID |
| aliyunMainId | String | 阿里云主账号ID |
| assetId | String | 资产 ID |
| assetType | String | 资产类型IMAGE/VIDEO/AUDIO |
| assetSource | String | 资产来源MODEL_GENERATED/OFFICIAL/USER_UPLOADED |
| favorited | Boolean | 是否被收藏 |
| assetName | String | 资产名称 |
| assetDescription | String | 资产描述 |
| assetSize | Long | 资产大小(字节) |
| md5 | String | 资产 MD5 |
| ossBucket | String | 资产所在 OSS Bucket |
| ossKey | String | 资产在 OSS bucket 中的 key |
| region | String | 工作空间地域 |
| ossRegion | String | 资产所在 OSS bucket 的地域 |
| trusted | Boolean | 是否可信 |
| modelType | String | 模型类型 |
| modelName | String | 模型型号 |
| syncWhiteListStatus | String | 同步白名单状态 |
| syncOssDataStatus | String | 同步 OSS 数据状态 |
| deleteStatus | String | 删除状态NORMAL/SOFT_DELETED/PERMANENTLY_DELETED |
| generateTime | Long | 资产生成时间戳(毫秒) |
| softDeleteDays | Integer | 已被软删除的天数(仅当 deleteStatus=SOFT_DELETED 且请求 softDeleteTimeOrder 时返回) |
| originalOssUrl | String | 原始 OSS URL |
| downloadUrl | String | 文件下载链接(仅当请求 includeDownloadUrl=true 时返回) |
| thumbnailUrl | String | 资产缩放图 URL仅当请求 includeThumbnail=true 时返回;视频返回首帧缩放图,图片返回缩放图,音频返回 null |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"pageSize": 20,
"includeDownloadUrl": true,
"includeThumbnail": true,
"thumbnailWidth": 200,
"thumbnailHeight": 200,
"assetType": "IMAGE",
"favorited": true,
"beginTime": "2024-01-01T00:00:00",
"endTime": "2024-12-31T23:59:59"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"dataList": [
{
"id": 1001,
"assetId": "asset-001",
"assetType": "IMAGE",
"assetName": "generated_image_01.png",
"assetDescription": "A landscape painting",
"favorited": true,
"generateTime": 1700000000000
}
],
"nextToken": 1000,
"hasNext": true,
"hasPre": false
}
}
```
---
## 13. 统计模型生成资产数量
**POST** `/zelda/api/v1/bailian/asset/countModelGeneratedAsset`
查询条件与 `listModelGeneratedAsset` 一致(不需要分页参数),按资产类型分组返回数量。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------------- | ------------ | ---- | ------------------------------------------------ |
| assetType | String(Enum) | 否 | 资产类型:`IMAGE` / `VIDEO` / `AUDIO` |
| favorited | Boolean | 否 | 是否被收藏 |
| assetName | String | 否 | 资产名称(子串模糊匹配) |
| assetDescription | String | 否 | 资产描述(子串模糊匹配) |
| trusted | Boolean | 否 | 是否可信 |
| modelType | String | 否 | 模型类型 |
| modelName | String | 否 | 模型型号 |
| syncWhiteListStatus | String(Enum) | 否 | 同步白名单状态 |
| syncOssDataStatus | String(Enum) | 否 | 同步OSS数据状态 |
| deleteStatus | String(Enum) | 否 | 删除状态 |
| beginTime | String | 否 | 资产生成时间起始(含),格式 ISO_LOCAL_DATE_TIME |
| endTime | String | 否 | 资产生成时间截止(含),格式 ISO_LOCAL_DATE_TIME |
### 响应 data
| 字段 | 类型 | 说明 |
| ---------- | ---- | ---------------- |
| imageCount | Long | 图片类型资产数量 |
| videoCount | Long | 视频类型资产数量 |
| audioCount | Long | 音频类型资产数量 |
| totalCount | Long | 总资产数量 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"deleteStatus": "NORMAL"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"imageCount": 150,
"videoCount": 30,
"audioCount": 20,
"totalCount": 200
}
}
```
---
## 14. 批量获取资产下载链接
**POST** `/zelda/api/v1/bailian/asset/batchGetAssetDownloadUrl`
一次最多获取 100 个资产的下载链接。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ----------- | ------------ | ---- | ------------------------------------------ |
| assetIdList | List<String> | 是 | 待获取下载链接的资产ID列表长度不超过 100 |
### 响应 data
| 字段 | 类型 | 说明 |
| ----- | -------------------------- | -------------------------------- |
| items | List<AssetDownloadUrlItem> | 资产下载链接列表,按请求顺序返回 |
**AssetDownloadUrlItem 结构:**
| 字段 | 类型 | 说明 |
| ----------- | ------ | ---------------------------------------------------------- |
| assetId | String | 资产 ID |
| downloadUrl | String | 资产下载链接(带签名);资产不存在或缺少 OSS 信息时为 null |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetIdList": ["asset-001", "asset-002", "asset-003"]
}
```
---
## 15. 查询模型生成资产详情
**POST** `/zelda/api/v1/bailian/asset/getModelGeneratedAsset`
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------------ | ------- | ---- | ------------------------------------------------ |
| assetId | String | 是 | 待查询的资产 ID |
| includeDownloadUrl | Boolean | 否 | 是否返回文件下载链接,默认 false |
| includeThumbnail | Boolean | 否 | 是否返回资产缩放图 URL默认 false |
| thumbnailWidth | Integer | 否 | 缩放图宽度像素includeThumbnail=true 时生效 |
| thumbnailHeight | Integer | 否 | 缩放图高度像素includeThumbnail=true 时生效 |
### 响应 data
| 字段 | 类型 | 说明 |
| ---- | ----------------------- | ------------------------ |
| item | ModelGeneratedAssetItem | 资产详情结构同第12节 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"assetId": "asset-001",
"includeDownloadUrl": true,
"includeThumbnail": true,
"thumbnailWidth": 200,
"thumbnailHeight": 200
}
```
---
## 16. 获取存储额度与用量
**POST** `/zelda/api/v1/bailian/asset/getStorageQuota`
### 请求参数
仅需公共参数(`workspace``tenantId` 必填)。
### 响应 data
| 字段 | 类型 | 说明 |
| ----------------- | ------ | --------------------------------------------- |
| freeStorageQuota | Long | 平台免费存储额度(单位:字节) |
| usedStorageSize | Long | 当前用户已使用的存储量(单位:字节) |
| extraStoragePrice | String | 超出免费额度的费用说明(如 "¥0.12元/GB/月" |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890"
}
```
---
## 19. 通用 MQ 消息发送
**POST** `/zelda/api/v1/bailian/asset/sendMqMessage`
向指定的 RocketMQ Producer 发送 JSON 格式的消息。producerType 对应 `EnumRocketMqProducerType` 枚举的 code 值mainAccountUid 作为消息路由 key。
### 请求参数
| 字段 | 类型 | 必填 | 说明 |
| ------------ | ------ | ---- | --------------------------------------------------------------------------------------------------- |
| producerType | String | 是 | 生产者类型:`WHITE_LIST_ASSET_PRODUCER` / `OSS_DATA_HANDEL_PRODUCER` / `ORIGIN_ASSET_INFO_PRODUCER` |
| messageBody | String | 是 | JSON 格式的消息体字符串 |
| messageKey | String | 否 | 消息 key可选为空时默认使用 mainAccountUid |
### 响应 data
| 字段 | 类型 | 说明 |
| ------- | ------- | ------------ |
| success | Boolean | 是否发送成功 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890",
"producerType": "ORIGIN_ASSET_INFO_PRODUCER",
"messageBody": "{\"time\":1700000000000,\"modelId\":\"model-abc\",\"type\":\"IMAGE\",\"workspace\":\"ws-xxxxx\",\"ossUrl\":\"oss://my-bucket/path/to/asset.png\"}"
}
```
---
## 21. 查询模型列表
**POST** `/zelda/api/v1/bailian/asset/listModels`
返回当前服务管理的模型配置列表按资产类型分组包含每个模型的ID及是否可信标识。
### 请求参数
仅需公共参数。
### 响应 data
| 字段 | 类型 | 说明 |
| ----------- | ---------------- | ---------------------------- |
| modelGroups | List<ModelGroup> | 按资产类型分组的模型配置列表 |
**ModelGroup 结构:**
| 字段 | 类型 | 说明 |
| --------- | --------------- | ------------------------------------- |
| assetType | String(Enum) | 资产类型:`IMAGE` / `VIDEO` / `AUDIO` |
| models | List<ModelItem> | 该类型下管理的模型列表 |
**ModelItem 结构:**
| 字段 | 类型 | 说明 |
| ------- | ------- | -------------- |
| modelId | String | 模型ID |
| trusted | Boolean | 该模型是否可信 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"modelGroups": [
{
"assetType": "IMAGE",
"models": [
{ "modelId": "qwen-image-3.0", "trusted": true },
{ "modelId": "qwen-image-3.0-pro", "trusted": true }
]
},
{
"assetType": "VIDEO",
"models": [
{ "modelId": "wan2.7-t2v", "trusted": true },
{ "modelId": "wan2.7-i2v", "trusted": true }
]
}
]
}
}
```
---
## 22. 查询用户是否已开通资产中心服务
**POST** `/zelda/api/v1/bailian/asset/checkAssetServiceSubscription`
查询当前用户是否已开通资产中心服务。
### 请求参数
仅需公共参数(`mainAccountUid` 必填)。
### 响应 data
| 字段 | 类型 | 说明 |
| ------- | ------- | ------------------------------------------------- |
| enabled | Boolean | 是否已开通资产中心服务true-已开通false-未开通 |
### 请求示例
```json
{
"workspace": "ws-xxxxx",
"tenantId": "123456",
"mainAccountUid": "1234567890"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"enabled": true
}
}
```
@@ -0,0 +1,67 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { AssetBatchResponse } from "./types.ts";
import {
ASSET_API,
ASSET_ID_FLAG,
callAssetApi,
dryRunPayload,
validateAssetIds,
} from "./utils.ts";
const DELETE_FLAGS = {
...ASSET_ID_FLAG,
permanent: {
type: "switch",
description: "Permanently delete assets (cannot be restored)",
},
} satisfies FlagsDef;
/**
* `bl asset-center delete` — 删除资产(默认软删到回收站)。
*
* 软删可恢复;--permanent 为永久删除。支持重复 --id单次最多 100 个)。
*/
export default defineCommand({
description: "Delete assets (soft delete to recycle bin by default)",
auth: "console",
usageArgs: "--id <asset-id> [--id <asset-id>...] [flags]",
flags: DELETE_FLAGS,
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002", "--id asset-001 --permanent"],
validate(flags) {
return validateAssetIds(flags.id);
},
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetIdList = flags.id;
const deleteType = flags.permanent ? "PERMANENT_DELETE" : "SOFT_DELETE";
const body = { assetIdList, deleteType };
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.batchDeleteAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetBatchResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchDeleteAsset,
body,
);
const verb = flags.permanent ? "Permanently deleted" : "Deleted";
if (settings.quiet || format === "text") {
emitBare(`${verb} ${data.affectedCount ?? assetIdList.length} asset(s).`);
} else {
emitResult(
{ affected_count: data.affectedCount ?? assetIdList.length, delete_type: deleteType },
format,
);
}
},
});
@@ -0,0 +1,76 @@
import {
defineCommand,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetDownloadResponse } from "./types.ts";
import { ASSET_API, callAssetApi, dryRunPayload } from "./utils.ts";
const DOWNLOAD_FLAGS = {
id: {
type: "string",
valueHint: "<asset-id>",
description: "Asset ID to get download URL for",
required: true,
},
} satisfies FlagsDef;
/**
* `bl asset-center download` — 通过资产 ID 获取签名下载链接。
*
* 调用 batchGetAssetDownloadUrl输出 download URL不落盘。
*/
export default defineCommand({
description: "Get a signed download URL for an asset by ID",
auth: "console",
usageArgs: "--id <asset-id>",
flags: DOWNLOAD_FLAGS,
exampleArgs: ["--id asset-001", "--id asset-001 --output json", "--id asset-001 --quiet"],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetId = flags.id;
const body = { assetIdList: [assetId] };
if (settings.dryRun) {
emitResult(
{
asset_id: assetId,
action: "download",
...dryRunPayload(settings, identity.binName, ASSET_API.batchGetAssetDownloadUrl, body),
},
format,
);
return;
}
const data = await callAssetApi<AssetDownloadResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchGetAssetDownloadUrl,
body,
);
const url = data.items?.[0]?.downloadUrl;
if (!url) {
throw new BailianError(`No download URL available for ${assetId}.`, ExitCode.GENERAL);
}
if (settings.quiet) {
emitBare(url);
return;
}
if (format === "json") {
emitResult({ asset_id: assetId, download_url: url }, format);
return;
}
emitBare(`${padEnd("AssetId", 16)} ${assetId}`);
emitBare(`${padEnd("DownloadUrl", 16)} ${url}`);
},
});
@@ -0,0 +1,54 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { AssetBatchResponse } from "./types.ts";
import {
ASSET_API,
ASSET_ID_FLAG,
callAssetApi,
dryRunPayload,
validateAssetIds,
} from "./utils.ts";
/**
* `bl asset-center favorite` — 收藏一个或多个资产。
*
* 支持重复 --id单次最多 100 个),调用 batchFavoriteAsset。
*/
export default defineCommand({
description: "Add assets to favorites",
auth: "console",
usageArgs: "--id <asset-id> [--id <asset-id>...]",
flags: ASSET_ID_FLAG,
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002"],
validate(flags) {
return validateAssetIds(flags.id);
},
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetIdList = flags.id;
const body = { assetIdList };
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.batchFavoriteAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetBatchResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchFavoriteAsset,
body,
);
if (settings.quiet || format === "text") {
emitBare(`Favorited ${data.affectedCount ?? assetIdList.length} asset(s).`);
} else {
emitResult({ affected_count: data.affectedCount ?? assetIdList.length }, format);
}
},
});
@@ -0,0 +1,95 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetGetResponse } from "./types.ts";
import { ASSET_API, callAssetApi, dryRunPayload, formatGenerateTime } from "./utils.ts";
const GET_FLAGS = {
assetId: {
type: "string",
valueHint: "<id>",
description: "Asset ID to query",
required: true,
},
includeDownloadUrl: {
type: "switch",
description: "Include signed download URL",
},
includeThumbnail: {
type: "switch",
description: "Include thumbnail URL",
},
thumbnailWidth: {
type: "number",
valueHint: "<px>",
description: "Thumbnail width in pixels",
},
thumbnailHeight: {
type: "number",
valueHint: "<px>",
description: "Thumbnail height in pixels",
},
} satisfies FlagsDef;
/**
* `bl asset-center get` — 按 ID 查询单个资产详情。
*
* 可选 --include-download-url / --include-thumbnail 获取签名 URL。
*/
export default defineCommand({
description: "Get full details of a model-generated asset",
auth: "console",
usageArgs: "--asset-id <id> [flags]",
flags: GET_FLAGS,
exampleArgs: [
"--asset-id asset-001",
"--asset-id asset-001 --include-download-url --output json",
],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetId = flags.assetId;
const body: Record<string, unknown> = { assetId };
if (flags.includeDownloadUrl) body.includeDownloadUrl = true;
if (flags.includeThumbnail) body.includeThumbnail = true;
if (flags.thumbnailWidth !== undefined) body.thumbnailWidth = flags.thumbnailWidth;
if (flags.thumbnailHeight !== undefined) body.thumbnailHeight = flags.thumbnailHeight;
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.getModelGeneratedAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetGetResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.getModelGeneratedAsset,
body,
);
const item = data.item;
if (!item) {
emitBare("Asset not found.");
return;
}
if (format === "json") {
emitResult(item, format);
return;
}
emitBare(`${padEnd("AssetId", 16)} ${item.assetId ?? "-"}`);
emitBare(`${padEnd("Type", 16)} ${item.assetType ?? "-"}`);
emitBare(`${padEnd("Name", 16)} ${item.assetName ?? "-"}`);
emitBare(`${padEnd("Description", 16)} ${item.assetDescription ?? "-"}`);
emitBare(`${padEnd("Model", 16)} ${item.modelName ?? "-"}`);
emitBare(`${padEnd("Favorited", 16)} ${item.favorited ? "yes" : "no"}`);
emitBare(`${padEnd("Generated", 16)} ${formatGenerateTime(item.generateTime)}`);
if (item.downloadUrl) emitBare(`${padEnd("DownloadUrl", 16)} ${item.downloadUrl}`);
if (item.thumbnailUrl) emitBare(`${padEnd("ThumbnailUrl", 16)} ${item.thumbnailUrl}`);
},
});
@@ -0,0 +1,150 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import type { AssetListResponse, ModelGeneratedAssetItem } from "./types.ts";
import {
ASSET_API,
ASSET_LIST_FILTER_FLAGS,
buildListFilterBody,
callAssetApi,
dryRunPayload,
formatGenerateTime,
} from "./utils.ts";
const LIST_FLAGS = {
...ASSET_LIST_FILTER_FLAGS,
includeDownloadUrl: {
type: "switch",
description: "Include signed download URLs in the response",
},
includeThumbnail: {
type: "switch",
description: "Include thumbnail URLs in the response",
},
thumbnailWidth: {
type: "number",
valueHint: "<px>",
description: "Thumbnail width in pixels",
},
thumbnailHeight: {
type: "number",
valueHint: "<px>",
description: "Thumbnail height in pixels",
},
pageSize: {
type: "number",
valueHint: "<n>",
description: "Results per page (default: 10, max: 100)",
},
nextToken: {
type: "number",
valueHint: "<token>",
description: "Cursor for the next page",
},
preToken: {
type: "number",
valueHint: "<token>",
description: "Cursor for the previous page",
},
} satisfies FlagsDef;
function normalizeItem(item: ModelGeneratedAssetItem) {
return {
asset_id: item.assetId ?? "",
asset_type: item.assetType ?? "",
asset_name: item.assetName ?? "",
model_name: item.modelName ?? "",
favorited: item.favorited ?? false,
generate_time: item.generateTime,
download_url: item.downloadUrl,
thumbnail_url: item.thumbnailUrl,
};
}
/**
* `bl asset-center list` — 分页查询模型生成资产列表。
*
* 支持类型/模型/关键词/收藏/回收站/OSS 同步状态/时间范围筛选,以及
* --next-token / --pre-token 游标翻页;可选返回签名下载链接与缩略图 URL。
*/
export default defineCommand({
description: "List model-generated assets with filters and cursor pagination",
auth: "console",
usageArgs: "[flags]",
flags: LIST_FLAGS,
exampleArgs: [
"",
"--type IMAGE --model qwen-image-3.0",
"--favorited --page-size 20",
"--recycle-bin",
"--keyword landscape --output json",
],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const pageSize = flags.pageSize ?? 10;
const body: Record<string, unknown> = {
...buildListFilterBody(flags),
pageSize,
};
if (flags.includeDownloadUrl) body.includeDownloadUrl = true;
if (flags.includeThumbnail) body.includeThumbnail = true;
if (flags.thumbnailWidth !== undefined) body.thumbnailWidth = flags.thumbnailWidth;
if (flags.thumbnailHeight !== undefined) body.thumbnailHeight = flags.thumbnailHeight;
if (flags.nextToken !== undefined) body.nextToken = flags.nextToken;
if (flags.preToken !== undefined) body.preToken = flags.preToken;
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.listModelGeneratedAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetListResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.listModelGeneratedAsset,
body,
);
const items = (data.dataList ?? []).map(normalizeItem);
if (format === "json") {
emitResult(
{
items,
pre_token: data.preToken,
next_token: data.nextToken,
has_next: data.hasNext,
has_pre: data.hasPre,
},
format,
);
return;
}
if (items.length === 0) {
emitBare("No assets found.");
return;
}
const headers = ["ASSET_ID", "TYPE", "NAME", "MODEL", "FAVORITED", "GENERATED"];
const rows = items.map((item) => [
item.asset_id,
item.asset_type,
item.asset_name,
item.model_name,
item.favorited ? "yes" : "-",
formatGenerateTime(item.generate_time),
]);
for (const line of formatTable(headers, rows)) emitBare(line);
const parts: string[] = [];
if (data.hasPre) parts.push("has previous page");
if (data.hasNext) parts.push(`next token: ${data.nextToken}`);
if (parts.length > 0) emitBare(`\n${parts.join("; ")}`);
},
});
@@ -0,0 +1,86 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetCountResponse } from "./types.ts";
import {
ASSET_API,
ASSET_LIST_FILTER_FLAGS,
buildListFilterBody,
callAssetApi,
dryRunPayload,
} from "./utils.ts";
const STATS_FLAGS = {
...ASSET_LIST_FILTER_FLAGS,
syncFailed: {
type: "switch",
description: "Also count assets with failed OSS sync",
},
} satisfies FlagsDef;
/**
* `bl asset-center stats` — 按类型统计模型生成资产数量。
*
* 复用 list 的筛选条件;--sync-failed 额外统计 OSS 同步失败的资产数。
*/
export default defineCommand({
description: "Count model-generated assets by type",
auth: "console",
usageArgs: "[flags]",
flags: STATS_FLAGS,
exampleArgs: ["", "--sync-failed", "--type IMAGE --output json"],
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const body = buildListFilterBody(flags);
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.countModelGeneratedAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetCountResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.countModelGeneratedAsset,
body,
);
let syncFailedCount: number | undefined;
if (flags.syncFailed) {
const failed = await callAssetApi<AssetCountResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.countModelGeneratedAsset,
{ ...body, syncOssDataStatus: "SYNC_FAILED" },
);
syncFailedCount = failed.totalCount ?? 0;
}
if (format === "json") {
emitResult(
{
total_count: data.totalCount ?? 0,
image_count: data.imageCount ?? 0,
video_count: data.videoCount ?? 0,
audio_count: data.audioCount ?? 0,
...(syncFailedCount !== undefined ? { sync_failed_count: syncFailedCount } : {}),
},
format,
);
return;
}
emitBare(`${padEnd("Total", 14)} ${data.totalCount ?? 0}`);
emitBare(`${padEnd("Image", 14)} ${data.imageCount ?? 0}`);
emitBare(`${padEnd("Video", 14)} ${data.videoCount ?? 0}`);
emitBare(`${padEnd("Audio", 14)} ${data.audioCount ?? 0}`);
if (syncFailedCount !== undefined) {
emitBare(`${padEnd("Sync failed", 14)} ${syncFailedCount}`);
}
},
});
@@ -0,0 +1,47 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
import type { AssetStorageQuotaResponse } from "./types.ts";
import { ASSET_API, callAssetApi, dryRunPayload, formatStorageBytes } from "./utils.ts";
/**
* `bl asset-center storage` — 查看存储配额、已用容量与超额计费说明。
*/
export default defineCommand({
description: "View storage quota, usage, and overage pricing",
auth: "console",
usageArgs: "[flags]",
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings, identity } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(dryRunPayload(settings, identity.binName, ASSET_API.getStorageQuota, {}), format);
return;
}
const data = await callAssetApi<AssetStorageQuotaResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.getStorageQuota,
{},
);
if (format === "json") {
emitResult(
{
used_storage_size: data.usedStorageSize,
free_storage_quota: data.freeStorageQuota,
extra_storage_price: data.extraStoragePrice,
},
format,
);
return;
}
emitBare(`${padEnd("Used", 14)} ${formatStorageBytes(data.usedStorageSize)}`);
emitBare(`${padEnd("Free quota", 14)} ${formatStorageBytes(data.freeStorageQuota)}`);
emitBare(`${padEnd("Overage", 14)} ${data.extraStoragePrice ?? "-"}`);
},
});
@@ -0,0 +1,72 @@
export type AssetType = "IMAGE" | "VIDEO" | "AUDIO";
export type AssetDeleteStatus = "NORMAL" | "SOFT_DELETED" | "PERMANENTLY_DELETED";
export type AssetSyncOssStatus = "NOT_SYNCED" | "IN_SYNCING" | "SYNC_SUCCESS" | "SYNC_FAILED";
export type AssetDeleteType = "SOFT_DELETE" | "PERMANENT_DELETE";
export interface AssetHttpBaseRequest {
workspace: string;
tenantId?: string;
mainAccountUid?: string;
apiSource?: string;
}
export interface ModelGeneratedAssetItem {
id?: number;
assetId?: string;
assetType?: string;
assetName?: string;
assetDescription?: string;
favorited?: boolean;
assetSize?: number;
modelType?: string;
modelName?: string;
deleteStatus?: string;
syncOssDataStatus?: string;
generateTime?: number;
downloadUrl?: string;
thumbnailUrl?: string;
gmtCreate?: string;
gmtModified?: string;
}
export interface AssetListResponse {
dataList?: ModelGeneratedAssetItem[];
preToken?: number;
nextToken?: number;
hasNext?: boolean;
hasPre?: boolean;
}
export interface AssetGetResponse {
item?: ModelGeneratedAssetItem;
}
export interface AssetBatchResponse {
success?: boolean;
affectedCount?: number;
}
export interface AssetDownloadUrlItem {
assetId?: string;
downloadUrl?: string | null;
}
export interface AssetDownloadResponse {
items?: AssetDownloadUrlItem[];
}
export interface AssetCountResponse {
imageCount?: number;
videoCount?: number;
audioCount?: number;
totalCount?: number;
}
export interface AssetStorageQuotaResponse {
freeStorageQuota?: number;
usedStorageSize?: number;
extraStoragePrice?: string;
}
@@ -0,0 +1,54 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import type { AssetBatchResponse } from "./types.ts";
import {
ASSET_API,
ASSET_ID_FLAG,
callAssetApi,
dryRunPayload,
validateAssetIds,
} from "./utils.ts";
/**
* `bl asset-center unfavorite` — 取消收藏一个或多个资产。
*
* 支持重复 --id单次最多 100 个),调用 batchUnfavoriteAsset。
*/
export default defineCommand({
description: "Remove assets from favorites",
auth: "console",
usageArgs: "--id <asset-id> [--id <asset-id>...]",
flags: ASSET_ID_FLAG,
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002"],
validate(flags) {
return validateAssetIds(flags.id);
},
async run(ctx) {
const { settings, identity, flags } = ctx;
const format = detectOutputFormat(settings.output);
const assetIdList = flags.id;
const body = { assetIdList };
if (settings.dryRun) {
emitResult(
dryRunPayload(settings, identity.binName, ASSET_API.batchUnfavoriteAsset, body),
format,
);
return;
}
const data = await callAssetApi<AssetBatchResponse>(
ctx.client,
settings,
identity.binName,
ASSET_API.batchUnfavoriteAsset,
body,
);
if (settings.quiet || format === "text") {
emitBare(`Unfavorited ${data.affectedCount ?? assetIdList.length} asset(s).`);
} else {
emitResult({ affected_count: data.affectedCount ?? assetIdList.length }, format);
}
},
});
@@ -0,0 +1,209 @@
import {
BailianError,
ExitCode,
effectiveConsoleGatewayConfig,
type Client,
type FlagsDef,
type ParsedFlags,
type Settings,
} from "bailian-cli-core";
import type { AssetHttpBaseRequest, AssetSyncOssStatus, AssetType } from "./types.ts";
const ASSET_SERVICE = "dashscopeModel";
const ASSET_BASE = "/zelda/api/v1/bailian/asset";
export const MAX_ASSET_BATCH_SIZE = 100;
export const ASSET_API = {
listModelGeneratedAsset: assetApi("listModelGeneratedAsset"),
getModelGeneratedAsset: assetApi("getModelGeneratedAsset"),
batchFavoriteAsset: assetApi("batchFavoriteAsset"),
batchUnfavoriteAsset: assetApi("batchUnfavoriteAsset"),
batchDeleteAsset: assetApi("batchDeleteAsset"),
batchGetAssetDownloadUrl: assetApi("batchGetAssetDownloadUrl"),
countModelGeneratedAsset: assetApi("countModelGeneratedAsset"),
getStorageQuota: assetApi("getStorageQuota"),
} as const;
export const ASSET_ID_FLAG = {
id: {
type: "array",
valueHint: "<asset-id>",
description: "Asset ID(s) to operate on (repeatable, max 100)",
required: true,
},
} satisfies FlagsDef;
export const ASSET_LIST_FILTER_FLAGS = {
type: {
type: "string",
valueHint: "<type>",
description: "Asset type: IMAGE, VIDEO, or AUDIO",
choices: ["IMAGE", "VIDEO", "AUDIO"] as const,
},
model: {
type: "string",
valueHint: "<name>",
description: "Filter by model name",
},
keyword: {
type: "string",
valueHint: "<text>",
description: "Filter by asset name (substring match)",
},
favorited: {
type: "switch",
description: "Show or count only favorited assets",
},
recycleBin: {
type: "switch",
description: "Show or count soft-deleted assets (recycle bin)",
},
syncStatus: {
type: "string",
valueHint: "<status>",
description: "OSS sync status filter",
choices: ["NOT_SYNCED", "IN_SYNCING", "SYNC_SUCCESS", "SYNC_FAILED"] as const,
},
beginTime: {
type: "string",
valueHint: "<datetime>",
description: "Filter by generate time start (ISO_LOCAL_DATE_TIME)",
},
endTime: {
type: "string",
valueHint: "<datetime>",
description: "Filter by generate time end (ISO_LOCAL_DATE_TIME)",
},
} satisfies FlagsDef;
type AssetListFilterFlags = ParsedFlags<typeof ASSET_LIST_FILTER_FLAGS>;
function assetApi(action: string): string {
return `zeldaHttp.${ASSET_SERVICE}.${ASSET_BASE}/${action}`;
}
function getNestedRecord(
obj: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
return undefined;
}
export function extractAssetResponse<T>(result: unknown): T {
const raw = result as Record<string, unknown>;
const data = getNestedRecord(raw, "data");
if (!data) {
throw new BailianError("Unexpected empty response from asset API.", ExitCode.GENERAL);
}
const dataV2 = getNestedRecord(data, "DataV2");
const payload = dataV2
? (getNestedRecord(getNestedRecord(dataV2, "data") ?? dataV2, "data") ??
getNestedRecord(dataV2, "data") ??
dataV2)
: (getNestedRecord(data, "data") ?? data);
if (payload.success === false) {
const message =
typeof payload.message === "string" && payload.message.length > 0
? payload.message
: typeof payload.code === "string"
? payload.code
: "Asset API request failed.";
throw new BailianError(message, ExitCode.GENERAL);
}
if (payload.data !== undefined) {
return payload.data as T;
}
return payload as T;
}
export function requireWorkspaceId(settings: Settings, binName: string): string {
if (settings.workspaceId) return settings.workspaceId;
throw new BailianError(
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
ExitCode.GENERAL,
`Run \`${binName} workspace list\` to view available workspaces.`,
);
}
export function buildBaseRequest(settings: Settings, binName: string): AssetHttpBaseRequest {
// workspace 由 CLI 注入tenantId / mainAccountUid 由 Console 网关从登录 session 自动填充,
// CLI 侧无需也不应手动解析阿里云账号 ID。
return {
workspace: requireWorkspaceId(settings, binName),
apiSource: "CLI",
};
}
export function buildListFilterBody(flags: AssetListFilterFlags): Record<string, unknown> {
const body: Record<string, unknown> = {};
if (flags.type) body.assetType = flags.type as AssetType;
if (flags.model) body.modelName = flags.model;
if (flags.keyword) body.assetName = flags.keyword;
if (flags.favorited) body.favorited = true;
if (flags.recycleBin) {
body.deleteStatus = "SOFT_DELETED";
} else {
body.deleteStatus = "NORMAL";
}
if (flags.syncStatus) body.syncOssDataStatus = flags.syncStatus as AssetSyncOssStatus;
if (flags.beginTime) body.beginTime = flags.beginTime;
if (flags.endTime) body.endTime = flags.endTime;
return body;
}
export function validateAssetIds(ids: string[] | undefined): string | undefined {
if (!ids || ids.length === 0) {
return "At least one --id is required.";
}
if (ids.length > MAX_ASSET_BATCH_SIZE) {
return `At most ${MAX_ASSET_BATCH_SIZE} asset IDs are allowed per request.`;
}
return undefined;
}
export async function callAssetApi<T>(
client: Client,
settings: Settings,
binName: string,
api: string,
body: Record<string, unknown>,
): Promise<T> {
const payload = { ...buildBaseRequest(settings, binName), ...body };
const raw = await client.console(api, payload);
return extractAssetResponse<T>(raw);
}
export function dryRunPayload(
settings: Settings,
binName: string,
api: string,
body: Record<string, unknown>,
): Record<string, unknown> {
return {
api,
data: { ...buildBaseRequest(settings, binName), ...body },
...effectiveConsoleGatewayConfig(settings),
};
}
export function formatGenerateTime(ts?: number): string {
if (ts == null) return "-";
return new Date(ts).toISOString().replace("T", " ").slice(0, 19);
}
export function formatStorageBytes(bytes?: number): string {
if (bytes == null) return "-";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
@@ -0,0 +1,79 @@
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
/** Read-only auth snapshot the config UI account widget renders. bl stores no
* user profile (name/avatar), so this exposes only which credential domains
* resolve, the console region/site, and a masked token. */
export interface AuthUiStatus {
authenticated: boolean;
methods: { apiKey: boolean; console: boolean; openapi: boolean };
primary: "console" | "apiKey" | "openapi" | null;
region?: string;
site?: "domestic" | "international";
masked?: string;
}
/**
* The auth capability surface the config UI is allowed to use. All `authStore`
* access is kept inside this module (commands/auth/**), which the lint boundary
* permits; commands/config/** consumes only this opaque bridge and never
* touches `authStore` directly.
*/
export interface AuthUiBridge {
status(): AuthUiStatus;
/** Start browser-based console login (fire-and-forget; UI polls status). */
startConsoleLogin(): void;
/** Clear all stored credentials. Returns whether anything changed. */
logout(): Promise<boolean>;
}
/** Build the bridge from a command context (identity/settings/authStore). */
export function makeAuthUiBridge(ctx: {
identity: Identity;
settings: Settings;
authStore: AuthStore;
}): AuthUiBridge {
const { identity, settings, authStore } = ctx;
return {
status() {
const a = authStore.describe();
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
let masked: string | undefined;
if (a.console) masked = maskToken(a.console.token);
else if (a.apiKey) masked = maskToken(a.apiKey.token);
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
return {
authenticated: methods.apiKey || methods.console || methods.openapi,
methods,
primary,
region: a.console?.region,
site: a.console?.site,
masked,
};
},
startConsoleLogin() {
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
// Mirror the CLI (`bl auth login --console`): request an api_key from the
// console only when one isn't already stored, so a first console login in
// the config UI also provisions the model api_key (not just access_token).
const hasApiKey = !!authStore.stored().apiKey;
// runConsoleLogin opens the browser and runs its own callback server
// (up to 15 min). We don't await it — the config UI polls the status
// endpoint to detect completion. Errors are logged, not surfaced.
void runConsoleLogin(
origin,
{ identity, settings, authStore },
{
needApiKey: !hasApiKey,
},
).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`console login failed: ${msg}\n`);
});
},
logout() {
return authStore.logout("all");
},
};
}
@@ -57,7 +57,7 @@ export async function validateAndPersistApiKey(
const persistBaseUrl = profile.persistBaseUrl
? normalizeModelBaseUrl(profile.persistBaseUrl)
: undefined;
const validationModel = "qwen3.7-max";
const validationModel = "qwen3.8-max";
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
@@ -0,0 +1,131 @@
/**
* Best-effort local launcher for coding-agent CLIs surfaced in the config UI.
*
* The command for each agent is taken from a fixed allowlist keyed by the
* agent id, so no user-controlled string is ever executed. Every child process
* is spawned via `execFile` (array args, no shell) to avoid injection.
*/
import { execFile } from "node:child_process";
/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */
export const AGENT_COMMANDS: Record<string, string> = {
"claude-code": "claude",
"qwen-code": "qwen",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
codex: "codex",
};
/** The launch binary for a known agent id, or undefined when unknown. */
export function agentCommand(id: string): string | undefined {
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
}
/**
* Per-agent argv that passes an initial task prompt while keeping the agent
* interactive in the terminal. Only verified contracts are listed; an agent
* absent here cannot be dispatched a prompt (its bare launch still works).
* - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive)
* - claude-code: `claude "<prompt>"` (positional initial prompt)
* - codex: `codex "<prompt>"` (positional initial prompt)
*/
const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = {
"qwen-code": (p) => ["-i", p],
"claude-code": (p) => [p],
codex: (p) => [p],
};
/** Whether a known agent supports being dispatched an initial task prompt. */
export function agentSupportsPrompt(id: string): boolean {
return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id);
}
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
function onPath(bin: string): Promise<boolean> {
const cmd = process.platform === "win32" ? "where" : "which";
return new Promise((resolve) => {
execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err));
});
}
/**
* Whether a known agent can actually be quick-launched right now: its id maps to
* a launch binary and that binary is reachable on PATH. Unknown ids resolve to
* false. Used to gate the UI's Quick launch button so "Connected" agents whose
* CLI is not installed do not offer a launch that would immediately fail.
*/
export function agentLaunchable(id: string): Promise<boolean> {
const command = agentCommand(id);
if (!command) return Promise.resolve(false);
return onPath(command);
}
/** Single-quote a path for a POSIX shell command line. */
function shQuote(p: string): string {
return `'${p.replace(/'/g, "'\\''")}'`;
}
/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */
function spawnTerminal(command: string, cwd: string): Promise<void> {
const platform = process.platform;
return new Promise((resolve, reject) => {
if (platform === "darwin") {
const inner = `cd ${shQuote(cwd)} && ${command}`;
const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const args = [
"-e",
`tell application "Terminal" to do script "${escaped}"`,
"-e",
'tell application "Terminal" to activate',
];
execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
return;
}
if (platform === "win32") {
const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`];
execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
return;
}
// Linux / other: best-effort via the distro's default terminal emulator.
const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`;
execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) =>
err ? reject(new Error("No supported terminal emulator was found")) : resolve(),
);
});
}
export interface LaunchResult {
launched: boolean;
command: string;
}
/**
* Launch a known coding agent's local CLI in a new terminal window. When
* `prompt` is provided, it is passed as a single quoted argument using the
* agent's verified prompt contract so the agent starts with that task.
* Rejects when the id is unknown, the binary is missing from PATH, the agent
* does not support prompt dispatch, or the platform terminal could not open.
*/
export async function launchAgent(
id: string,
cwd: string = process.cwd(),
prompt?: string,
): Promise<LaunchResult> {
const command = agentCommand(id);
if (!command) throw new Error(`Unknown agent: ${id}`);
if (!(await onPath(command))) {
throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`);
}
let fullCommand = command;
const task = (prompt ?? "").trim();
if (task) {
const build = AGENT_PROMPT_ARGV[id];
if (!build) throw new Error(`${id} does not support dispatching a task prompt.`);
// shQuote keeps the whole prompt as one shell argument (no injection); the
// platform terminal layer escapes the resulting command line separately.
fullCommand = [command, ...build(task).map(shQuote)].join(" ");
}
await spawnTerminal(fullCommand, cwd);
return { launched: true, command: fullCommand };
}
@@ -0,0 +1,160 @@
// Read/manage the local assets that `bl` writes into the output directory
// (default ~/bailian-output, overridable via the `output_dir` config key).
// Generated media may live directly under the base or in any subfolder (bl's
// own images/, videos/, speech/, omni/, or user-created folders). This module
// recursively discovers every file under the base, classifies each by type,
// derives its category from the top-level folder, and provides safe path
// resolution for serving/deleting individual assets.
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
import { homedir } from "node:os";
import { join, extname, relative, resolve, sep } from "node:path";
export type AssetKind = "image" | "video" | "audio" | "other";
/** One generated file discovered under the output directory. */
export interface AssetInfo {
name: string;
/** Category folder the file lives in: images | videos | speech | omni | other. */
category: string;
kind: AssetKind;
/** Path relative to the output base (used as the API handle). */
relPath: string;
size: number;
/** Modification time in epoch milliseconds ~= generation time. */
mtime: number;
ext: string;
}
/** Max directory depth to descend from the output base when scanning. */
const MAX_SCAN_DEPTH = 8;
const KIND_BY_EXT: Record<string, AssetKind> = {
".png": "image",
".jpg": "image",
".jpeg": "image",
".webp": "image",
".gif": "image",
".bmp": "image",
".svg": "image",
".mp4": "video",
".mov": "video",
".webm": "video",
".mkv": "video",
".avi": "video",
".mp3": "audio",
".wav": "audio",
".m4a": "audio",
".aac": "audio",
".flac": "audio",
".ogg": "audio",
};
const CONTENT_TYPE: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
".aac": "audio/aac",
".flac": "audio/flac",
".ogg": "audio/ogg",
};
/** The default output base when `output_dir` is not configured. */
export function defaultOutputBase(home: string = homedir()): string {
return join(home, "bailian-output");
}
function kindOf(ext: string): AssetKind {
return KIND_BY_EXT[ext.toLowerCase()] ?? "other";
}
/** MIME type for serving an asset; falls back to a safe binary type. */
export function contentType(ext: string): string {
return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream";
}
/** Recursively collect regular files under `dir`, descending at most `depth` levels. */
function walk(dir: string, depth: number, out: string[]): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (depth > 0) walk(full, depth - 1, out);
} else if (e.isFile() || e.isSymbolicLink()) {
out.push(full);
}
}
}
/**
* List generated assets under `base`, newest first. Recursively scans every
* subfolder under the base (plus loose files at the root), so assets in bl's
* own category dirs and any user-created folders are all discovered. Each
* file's `category` is its top-level folder name, or "other" for root files.
* Returns the resolved base so callers can surface it in the UI.
*/
export function listAssets(base: string = defaultOutputBase()): {
base: string;
assets: AssetInfo[];
} {
const assets: AssetInfo[] = [];
if (!existsSync(base)) return { base, assets };
const files: string[] = [];
walk(base, MAX_SCAN_DEPTH, files);
for (const full of files) {
let st;
try {
st = statSync(full);
} catch {
continue;
}
if (!st.isFile()) continue;
const rel = relative(base, full);
const segments = rel.split(sep);
const category = segments.length > 1 ? segments[0]! : "other";
const ext = extname(full);
assets.push({
name: full.split(sep).pop() ?? full,
category,
kind: kindOf(ext),
relPath: rel,
size: st.size,
mtime: st.mtimeMs,
ext: ext.replace(/^\./, "").toLowerCase(),
});
}
assets.sort((a, b) => b.mtime - a.mtime);
return { base, assets };
}
/**
* Resolve a client-supplied relative path to an absolute path strictly inside
* `base`. Returns null for empty input or any path that would escape the base
* (path traversal guard).
*/
export function resolveAssetPath(base: string, relPath: string): string | null {
if (typeof relPath !== "string" || relPath.length === 0) return null;
const root = resolve(base);
const abs = resolve(root, relPath);
if (abs !== root && !abs.startsWith(root + sep)) return null;
return abs;
}
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
/**
* Minimal, dependency-free QR Code encoder used by the config UI to show a
* scannable code for the current session URL.
*
* Scope is deliberately narrow: byte mode, error-correction level L, versions
* 15 (21x21 … 37x37). Restricting to level L keeps every supported version a
* single ReedSolomon block, so no codeword interleaving is required. Version 5
* (level L) holds up to 108 data bytes, comfortably more than a
* `http://127.0.0.1:<port>/?token=<hex>` URL.
*
* The output is an SVG string with a 4-module quiet zone and a `viewBox` only
* (no fixed width/height), so the caller sizes it via CSS.
*/
// --- GF(256) arithmetic (primitive polynomial 0x11D) ---
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
function gmul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return EXP[LOG[a] + LOG[b]];
}
/** ReedSolomon generator polynomial for `degree` EC codewords (alpha exponents). */
export function rsGeneratorExp(degree: number): number[] {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next: number[] = Array.from({ length: poly.length + 1 }, () => 0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= poly[j];
next[j + 1] ^= gmul(poly[j], EXP[i]);
}
poly = next;
}
return poly.map((v) => LOG[v]);
}
/** Compute `ecLen` ReedSolomon error-correction codewords for `data`. */
export function rsEncode(data: number[], ecLen: number): number[] {
const gen = rsGeneratorExp(ecLen);
const res = new Uint8Array(data.length + ecLen);
res.set(data, 0);
for (let i = 0; i < data.length; i++) {
const coef = res[i];
if (coef !== 0) {
const lead = LOG[coef];
for (let j = 0; j < gen.length; j++) res[i + j] ^= EXP[(gen[j] + lead) % 255];
}
}
return Array.from(res.slice(data.length));
}
// --- Capacity table: [data codewords, EC codewords] per version at level L ---
const CAP_L: Array<[number, number]> = [
[19, 7], // V1 (21x21)
[34, 10], // V2 (25x25)
[55, 15], // V3 (29x29)
[80, 20], // V4 (33x33)
[108, 26], // V5 (37x37)
];
const EC_BITS_L = 0b01; // format-info error-correction level bits for L
function pickVersion(byteLen: number): number {
const bits = 4 + 8 + byteLen * 8; // mode + 8-bit count (V19) + payload
for (let v = 0; v < CAP_L.length; v++) {
if (CAP_L[v][0] * 8 >= bits) return v + 1;
}
throw new Error("qr: data too large for supported versions (max 108 bytes)");
}
// --- Bit/codeword assembly ---
function toCodewords(bytes: Uint8Array, version: number): number[] {
const [dataCw] = CAP_L[version - 1];
const bits: number[] = [];
const put = (val: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
};
put(0b0100, 4); // byte mode
put(bytes.length, 8); // character count (versions 19)
for (const b of bytes) put(b, 8);
const capBits = dataCw * 8;
put(0, Math.min(4, capBits - bits.length)); // terminator
while (bits.length % 8 !== 0) bits.push(0); // pad to byte
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let v = 0;
for (let j = 0; j < 8; j++) v = (v << 1) | bits[i + j];
data.push(v);
}
const pads = [0xec, 0x11];
for (let p = 0; data.length < dataCw; p++) data.push(pads[p % 2]);
return data.concat(rsEncode(data, CAP_L[version - 1][1]));
}
// --- Matrix construction ---
interface Grid {
size: number;
mod: Uint8Array; // 0/1
fn: Uint8Array; // 1 = function/reserved module (skip during data placement)
}
function newGrid(size: number): Grid {
return { size, mod: new Uint8Array(size * size), fn: new Uint8Array(size * size) };
}
function setFn(g: Grid, r: number, c: number, dark: number): void {
g.mod[r * g.size + c] = dark;
g.fn[r * g.size + c] = 1;
}
function drawFinder(g: Grid, r: number, c: number): void {
for (let dr = -1; dr <= 7; dr++) {
for (let dc = -1; dc <= 7; dc++) {
const rr = r + dr;
const cc = c + dc;
if (rr < 0 || rr >= g.size || cc < 0 || cc >= g.size) continue;
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
const isDark =
inRing &&
(dr === 0 ||
dr === 6 ||
dc === 0 ||
dc === 6 ||
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
setFn(g, rr, cc, isDark ? 1 : 0);
}
}
}
function drawAlignment(g: Grid, cr: number, cc: number): void {
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
const ring = Math.max(Math.abs(dr), Math.abs(dc));
setFn(g, cr + dr, cc + dc, ring === 1 ? 0 : 1);
}
}
}
function drawFunctionPatterns(g: Grid, version: number): void {
const size = g.size;
// Timing patterns.
for (let i = 0; i < size; i++) {
setFn(g, 6, i, i % 2 === 0 ? 1 : 0);
setFn(g, i, 6, i % 2 === 0 ? 1 : 0);
}
// Finder patterns + separators (drawn as the -1 border above).
drawFinder(g, 0, 0);
drawFinder(g, 0, size - 7);
drawFinder(g, size - 7, 0);
// Alignment pattern (single, centered) for versions 25.
if (version >= 2) {
const pos = size - 7; // e.g. 18 (V2), 22 (V3), 26 (V4), 30 (V5)
drawAlignment(g, pos, pos);
}
// Reserve format-info areas (values written later).
for (let i = 0; i < 9; i++) {
if (!(i === 6)) g.fn[8 * size + i] = 1;
if (!(i === 6)) g.fn[i * size + 8] = 1;
}
g.fn[8 * size + 6] = 1;
g.fn[6 * size + 8] = 1;
for (let i = 0; i < 8; i++) g.fn[(size - 1 - i) * size + 8] = 1;
for (let i = 0; i < 8; i++) g.fn[8 * size + (size - 1 - i)] = 1;
// Dark module.
setFn(g, size - 8, 8, 1);
}
function placeData(g: Grid, codewords: number[]): void {
const size = g.size;
const stream: number[] = [];
for (const cw of codewords) for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1);
let idx = 0;
let upward = true;
for (let col = size - 1; col >= 1; col -= 2) {
if (col === 6) col = 5; // skip the vertical timing column
for (let i = 0; i < size; i++) {
const row = upward ? size - 1 - i : i;
for (const off of [0, 1]) {
const cc = col - off;
if (g.fn[row * size + cc]) continue;
g.mod[row * size + cc] = idx < stream.length ? stream[idx++] : 0;
}
}
upward = !upward;
}
}
const MASKS: Array<(r: number, c: number) => boolean> = [
(r, c) => (r + c) % 2 === 0,
(r) => r % 2 === 0,
(_r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
function applyMask(g: Grid, mask: number): void {
const cond = MASKS[mask];
for (let r = 0; r < g.size; r++) {
for (let c = 0; c < g.size; c++) {
if (!g.fn[r * g.size + c] && cond(r, c)) g.mod[r * g.size + c] ^= 1;
}
}
}
function penalty(g: Grid): number {
const size = g.size;
const at = (r: number, c: number) => g.mod[r * size + c];
let score = 0;
// Rule 1: runs of >=5 same-color modules in rows and columns.
for (let r = 0; r < size; r++) {
let runC = 1;
let runR = 1;
for (let c = 1; c < size; c++) {
if (at(r, c) === at(r, c - 1)) runC++;
else {
if (runC >= 5) score += runC - 2;
runC = 1;
}
if (at(c, r) === at(c - 1, r)) runR++;
else {
if (runR >= 5) score += runR - 2;
runR = 1;
}
}
if (runC >= 5) score += runC - 2;
if (runR >= 5) score += runR - 2;
}
// Rule 2: 2x2 blocks of the same color.
for (let r = 0; r < size - 1; r++) {
for (let c = 0; c < size - 1; c++) {
const v = at(r, c);
if (v === at(r, c + 1) && v === at(r + 1, c) && v === at(r + 1, c + 1)) score += 3;
}
}
// Rule 3: finder-like 1:1:3:1:1 patterns.
const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
const match = (get: (k: number) => number, start: number, pat: number[]) => {
for (let k = 0; k < pat.length; k++) if (get(start + k) !== pat[k]) return false;
return true;
};
for (let r = 0; r < size; r++) {
for (let c = 0; c <= size - 11; c++) {
if (match((k) => at(r, k), c, pat1) || match((k) => at(r, k), c, pat2)) score += 40;
if (match((k) => at(k, r), c, pat1) || match((k) => at(k, r), c, pat2)) score += 40;
}
}
// Rule 4: proportion of dark modules.
let dark = 0;
for (let i = 0; i < size * size; i++) dark += g.mod[i];
const percent = (dark * 100) / (size * size);
const k = Math.floor(Math.abs(percent - 50) / 5);
score += k * 10;
return score;
}
function formatBits(mask: number): number {
const data = (EC_BITS_L << 3) | mask; // 5 bits
let rem = data << 10;
for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
return ((data << 10) | rem) ^ 0x5412;
}
function drawFormat(g: Grid, mask: number): void {
const size = g.size;
const fmt = formatBits(mask);
const bit = (i: number) => (fmt >> i) & 1;
// First copy: around the top-left finder. Bits 05 run down column 8
// (rows 05); bits 914 run left along row 8 (cols 50).
for (let i = 0; i <= 5; i++) g.mod[i * size + 8] = bit(i);
g.mod[7 * size + 8] = bit(6);
g.mod[8 * size + 8] = bit(7);
g.mod[8 * size + 7] = bit(8);
for (let i = 9; i < 15; i++) g.mod[8 * size + (14 - i)] = bit(i);
// Second copy: split across top-right and bottom-left.
for (let i = 0; i < 8; i++) g.mod[(size - 1 - i) * size + 8] = bit(i);
for (let i = 8; i < 15; i++) g.mod[8 * size + (size - 15 + i)] = bit(i);
g.mod[(size - 8) * size + 8] = 1; // dark module stays set
}
/** Build the final QR module matrix (true = dark) for `text`. */
export function qrMatrix(text: string): boolean[][] {
const bytes = new TextEncoder().encode(text);
const version = pickVersion(bytes.length);
const codewords = toCodewords(bytes, version);
const g = newGrid(17 + 4 * version);
drawFunctionPatterns(g, version);
placeData(g, codewords);
let best = 0;
let bestScore = Infinity;
for (let m = 0; m < 8; m++) {
applyMask(g, m);
drawFormat(g, m);
const s = penalty(g);
if (s < bestScore) {
bestScore = s;
best = m;
}
applyMask(g, m); // undo (XOR is its own inverse)
}
applyMask(g, best);
drawFormat(g, best);
const out: boolean[][] = [];
for (let r = 0; r < g.size; r++) {
const row: boolean[] = [];
for (let c = 0; c < g.size; c++) row.push(g.mod[r * g.size + c] === 1);
out.push(row);
}
return out;
}
/** Render `text` as an SVG QR code string (4-module quiet zone, viewBox only). */
export function qrSvg(text: string): string {
const m = qrMatrix(text);
const size = m.length;
const quiet = 4;
const dim = size + quiet * 2;
let rects = "";
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (m[r][c]) rects += `<rect x="${c + quiet}" y="${r + quiet}" width="1" height="1"/>`;
}
}
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
`shape-rendering="crispEdges" role="img" aria-label="QR code">` +
`<rect width="${dim}" height="${dim}" fill="#ffffff"/>` +
`<g fill="#000000">${rects}</g></svg>`
);
}
@@ -0,0 +1,166 @@
/**
* Curated "Playground" scenarios surfaced in the config UI.
*
* Each scenario is a fixed, reviewable prompt template that the UI can dispatch
* to a connected local coding agent (e.g. qwen-code), which then runs it in a
* new terminal. Optional `{{inputs}}` are filled by the user before dispatch.
*
* Prompts are defined here and never accepted as free-form text from the web,
* so the instruction handed to a local agent is always known and auditable.
*/
export interface ScenarioInput {
key: string;
label: string;
placeholder?: string;
}
export interface Scenario {
id: string;
title: string;
description: string;
category: string;
prompt: string;
inputs?: ScenarioInput[];
}
export const SCENARIOS: Scenario[] = [
// ---- 图像 ----
{
id: "image-generate",
title: "文生图",
description: "一键生成一张示例图片并保存到输出目录。",
category: "图像",
prompt:
"请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。",
},
{
id: "image-describe",
title: "图片理解",
description: "从输出目录任选一张图片,详细描述内容与风格。",
category: "图像",
prompt:
"请在输出目录(默认 output/images中任选一张图片用中文详细描述它的内容、主体、构图、色彩与风格并推测它适合的使用场景。若目录为空请说明。",
},
{
id: "image-alt-batch",
title: "批量 Alt 文本",
description: "为输出目录下的图片批量生成无障碍 alt 文本。",
category: "图像",
prompt:
"请扫描输出目录(默认 output/images下的所有图片逐张生成简洁、准确的 alt 无障碍描述,最后以「文件名 → alt 文本」的表格汇总。若目录为空请说明。",
},
{
id: "image-to-code",
title: "截图转代码",
description: "把输出目录里的界面截图还原成 HTML+CSS。",
category: "图像",
prompt:
"请在输出目录(默认 output/images中查找一张界面截图用 HTML + CSS 尽可能还原它的布局、间距与配色,输出为一个可直接在浏览器打开的单文件,并简述还原思路。若没有找到截图请说明。",
},
// ---- 音频 ----
{
id: "speech-generate",
title: "文字转语音",
description: "把一句示例文字合成为自然语音。",
category: "音频",
prompt:
"请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。",
},
{
id: "audio-summarize",
title: "音频转写总结",
description: "转写输出目录里的音频并提炼要点。",
category: "音频",
prompt:
"请在输出目录(默认 output/speech中找到一个音频文件转写其内容先给出完整文字再用要点列表总结关键信息。若目录为空或缺少转写能力请说明并尝试用可用的能力完成。",
},
// ---- 视频 ----
{
id: "video-generate",
title: "文生视频",
description: "一键生成一段示例短视频。",
category: "视频",
prompt:
"请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。",
},
{
id: "video-storyboard",
title: "视频分镜脚本",
description: "围绕示例主题产出可用于文生视频的分镜。",
category: "视频",
prompt:
"围绕主题「城市清晨的第一杯咖啡」,为一支 15-30 秒的短视频撰写分镜脚本:逐镜头给出画面描述、时长、字幕或旁白,并为每个镜头附上可直接用于文生视频的英文 prompt。",
},
// ---- 多模态 ----
{
id: "media-prompt-craft",
title: "多模态提示词",
description: "把一个示例创意扩展成图/视频/语音提示词。",
category: "多模态",
prompt:
"把创意「未来赛博城市的夜市」扩展成三组高质量生成提示词1) 文生图2) 文生视频3) 语音风格描述。每组给出中英对照,并简要说明关键参数建议。",
},
{
id: "image-story-narration",
title: "图片配音文案",
description: "为输出目录里的图片写解说词并给出可合成文本。",
category: "多模态",
prompt:
"请在输出目录(默认 output/images中任选一张图片为它撰写一段 60 秒左右的中文解说词(适合配音),语气生动。随后给出可直接用于语音合成的纯文本版本。若目录为空请说明。",
},
// ---- 代码 ----
{
id: "summarize-project",
title: "总结当前项目",
description: "让 agent 阅读当前目录,总结架构、技术栈与主要模块。",
category: "代码",
prompt:
"请阅读当前工作目录的项目结构和关键源码用简洁的中文总结1) 它是做什么的2) 技术栈3) 主要模块及其职责4) 值得注意的设计。先浏览再下结论,不要臆测。",
},
{
id: "write-tests",
title: "为核心模块写单测",
description: "自动挑选缺测试的核心模块并补全单元测试。",
category: "代码",
prompt:
"请在当前项目中挑选一个核心且缺少测试(或测试薄弱)的模块,为它编写全面的单元测试,覆盖主要逻辑分支和边界情况,并遵循本项目现有的测试框架与风格。先阅读相关文件及其依赖,再编写测试。",
},
{
id: "code-review",
title: "代码审查",
description: "审查当前项目核心代码,指出问题与改进建议。",
category: "代码",
prompt:
"请审查当前项目的核心源码,指出潜在的 bug、安全隐患、性能与可维护性问题并给出具体、可操作的改进建议按严重程度排序。先浏览项目结构选取关键文件再审查。",
},
{
id: "explain-code",
title: "解释核心代码",
description: "挑选入口或核心模块,解释其实现与依赖。",
category: "代码",
prompt:
"请挑选当前项目的入口文件或核心模块,解释它的实现:职责是什么、关键流程如何运转、依赖了哪些模块。用清晰的中文说明,必要时给出调用关系。",
},
// ---- 文档 ----
{
id: "generate-readme",
title: "生成 README",
description: "阅读代码后生成结构清晰、与实现一致的 README.md。",
category: "文档",
prompt:
"为当前工作目录的项目生成一个结构清晰的 README.md包含项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写内容必须与实际实现一致。",
},
];
/** Look up a scenario by id, or undefined when unknown. */
export function getScenario(id: string): Scenario | undefined {
return SCENARIOS.find((s) => s.id === id);
}
/** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */
export function renderScenarioPrompt(scenario: Scenario, values: Record<string, string>): string {
return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
const v = values[key];
return typeof v === "string" ? v.trim() : "";
});
}
@@ -32,6 +32,80 @@ export const SECRET_KEYS = new Set<string>([
"security_token",
]);
// The web UI edits the full ConfigFile, so it exposes these extra keys on top
// of VALID_KEYS (which `config set` keeps as its narrower, documented surface).
// This lets `config ui` surface and edit every field that lives in config.json
// rather than silently hiding console/telemetry settings.
export const UI_EXTRA_KEYS = [
"console_site",
"console_region",
"console_switch_agent",
"telemetry",
] as const;
export const UI_VALID_KEYS = [...VALID_KEYS, ...UI_EXTRA_KEYS] as const;
// Keys the UI renders as a fixed-choice dropdown instead of a free-text input.
export const UI_ENUM_KEYS: Record<string, string[]> = {
output: ["text", "json"],
console_site: ["domestic", "international"],
};
// Keys the UI renders as a true/false dropdown and stores as a boolean.
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
// Default model each `default_*_model` key falls back to when left unset. These
// mirror the inline `|| "<model>"` fallbacks in the generation commands
// (text/chat, image/generate, video/generate, speech/synthesize, omni/chat) and
// are surfaced as input placeholders so users can see the effective default
// without persisting a value that would pin the model.
export const UI_MODEL_DEFAULTS: Record<string, string> = {
default_text_model: "qwen3.8-max",
default_image_model: "qwen-image-3.0",
default_video_model: "happyhorse-1.1-t2v",
default_speech_model: "cosyvoice-v3-flash",
default_omni_model: "qwen3.5-omni-plus",
};
/** One selectable model plus a short note on where the CLI uses it. */
export interface ModelOption {
id: string;
role: string;
}
// A per-category catalog of the model names the `bl` pipeline actually
// references (packages/runtime/src/pipeline/steps/bl-api.ts, plus the advisor
// and agent-writer helpers). The UI groups these under each `default_*_model`
// field as click-to-fill suggestions; the first entry is the fallback default.
// Only names present in the codebase are listed here — no invented models.
export const UI_MODEL_CATALOG: Record<string, ModelOption[]> = {
default_text_model: [
{ id: "qwen3.8-max", role: "text/chat default" },
{ id: "qwen3-coder-plus", role: "coding-oriented (agent config)" },
{ id: "qwen-flash", role: "fast · advisor ranking" },
{ id: "qwen3.6-flash", role: "fast · advisor intent" },
],
default_image_model: [
{ id: "qwen-image-3.0", role: "image/generate default · sync" },
{ id: "qwen-image-2.0", role: "image/generate · sync" },
{ id: "qwen-image-max", role: "image/generate · sync" },
{ id: "qwen-image-edit-2.0", role: "image/edit · sync" },
{ id: "wanx2.x", role: "image/generate · async series" },
],
default_video_model: [
{ id: "happyhorse-1.1-t2v", role: "video/generate default · text-to-video" },
{ id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" },
],
default_speech_model: [
{ id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" },
{ id: "fun-asr", role: "speech/recognize (ASR)" },
],
default_omni_model: [
{ id: "qwen3.5-omni-plus", role: "omni/chat default" },
{ id: "qwen3-vl-plus", role: "vision/describe · multimodal input" },
],
};
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
export const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
@@ -92,3 +166,55 @@ export function validateAndCoerce(key: string, value: string): string | number {
return value;
}
/**
* Validate/coerce a value for the wider set of keys the web UI can edit
* (UI_VALID_KEYS). Standard keys delegate to `validateAndCoerce`; the UI-only
* extras (console_*, telemetry) are validated here. Booleans are returned as
* real booleans so they persist correctly in config.json.
*/
export function validateAndCoerceUi(key: string, value: string): string | number | boolean {
const resolvedKey = resolveKey(key);
if ((VALID_KEYS as readonly string[]).includes(resolvedKey)) {
return validateAndCoerce(key, value);
}
if (resolvedKey === "console_site") {
if (!["domestic", "international"].includes(value)) {
throw new BailianError(
`Invalid console_site "${value}". Valid values: domestic, international`,
ExitCode.USAGE,
);
}
return value;
}
if (resolvedKey === "console_region") return value;
if (resolvedKey === "console_switch_agent") {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) {
throw new BailianError(
`Invalid console_switch_agent "${value}". Must be a positive number.`,
ExitCode.USAGE,
);
}
return num;
}
if (resolvedKey === "telemetry") {
if (value !== "true" && value !== "false") {
throw new BailianError(
`Invalid telemetry "${value}". Valid values: true, false`,
ExitCode.USAGE,
);
}
return value === "true";
}
throw new BailianError(
`Invalid config key "${key}". Valid keys: ${UI_VALID_KEYS.join(", ")}`,
ExitCode.USAGE,
);
}
File diff suppressed because one or more lines are too long
+481 -17
View File
@@ -1,5 +1,7 @@
import http from "node:http";
import { randomBytes } from "node:crypto";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs";
import { extname } from "node:path";
import {
defineCommand,
@@ -10,13 +12,38 @@ import {
readConfigFile,
writeConfigFile,
deleteConfigProfile,
REGIONS,
type ConfigStore,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
import { listenLocalServer, openInBrowser, openPath } from "../shared/local-server.ts";
import { PAGE_HTML } from "./ui-html.ts";
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
import {
UI_VALID_KEYS,
UI_ENUM_KEYS,
UI_BOOLEAN_KEYS,
UI_MODEL_DEFAULTS,
UI_MODEL_CATALOG,
SECRET_KEYS,
resolveKey,
validateAndCoerceUi,
} from "./shared.ts";
import {
listSkills,
listMcpServers,
listAgents,
getSkillDetail,
getAgentDetail,
writeMcpServer,
deleteMcpServer,
installSkillZip,
} from "./inventory.ts";
import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts";
import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts";
import { qrSvg } from "./qr.ts";
import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts";
import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts";
const FLAGS = {
port: {
@@ -50,6 +77,7 @@ function readBody(req: http.IncomingMessage): Promise<string> {
size += chunk.length;
if (size > MAX_BODY) {
reject(new Error("payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
@@ -59,16 +87,47 @@ function readBody(req: http.IncomingMessage): Promise<string> {
});
}
/** Max size for binary uploads (skill .zip packages). */
const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB
function readBodyBuffer(req: http.IncomingMessage, max: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > max) {
reject(new Error("payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
/** Constant-time token comparison (avoids timing side channels). */
function tokenMatches(provided: string | null, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
/** Build the request cleaned/validated config block from a posted `data` map. */
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
const cleaned: Record<string, string | number> = {};
function buildProfilePatch(
data: Record<string, unknown>,
): Record<string, string | number | boolean> {
const cleaned: Record<string, string | number | boolean> = {};
for (const [k, v] of Object.entries(data)) {
let value = "";
if (typeof v === "string") value = v;
else if (typeof v === "number" || typeof v === "boolean") value = String(v);
// null/undefined/objects fall through as "" and clear the key
if (value === "") continue;
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
cleaned[resolveKey(k)] = validateAndCoerceUi(k, value);
}
return cleaned;
}
@@ -76,9 +135,9 @@ function buildProfilePatch(data: Record<string, unknown>): Record<string, string
/** Preserve valid Config fields that the UI does not expose or manage. */
function mergeUnmanagedProfileFields(
existing: Record<string, unknown>,
managedPatch: Record<string, string | number>,
managedPatch: Record<string, string | number | boolean>,
): Record<string, unknown> {
const managedKeys = new Set<string>(VALID_KEYS);
const managedKeys = new Set<string>(UI_VALID_KEYS);
const merged: Record<string, unknown> = {};
for (const [key, value] of Object.entries(existing)) {
if (!managedKeys.has(key)) merged[key] = value;
@@ -91,7 +150,12 @@ function mergeUnmanagedProfileFields(
* - Host header must be a loopback name (anti DNS-rebinding).
* - every request must carry `?token=` matching the session token.
*/
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
export function createConfigUiServer(
token: string,
configStore: ConfigStore,
outputBase: string = defaultOutputBase(),
authBridge?: AuthUiBridge,
): http.Server {
return http.createServer(async (req, res) => {
try {
const host = (req.headers.host || "").split(":")[0];
@@ -102,7 +166,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
}
const u = new URL(req.url ?? "/", "http://127.0.0.1");
if (u.searchParams.get("token") !== token) {
if (!tokenMatches(u.searchParams.get("token"), token)) {
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
res.end("unauthorized\n");
return;
@@ -112,17 +176,54 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
const path = u.pathname;
if (path === "/" && method === "GET") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
// The page URL carries the session token, so never cache it.
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy":
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " +
"media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
});
res.end(PAGE_HTML);
return;
}
if (path === "/api/qr" && method === "GET") {
const data = (u.searchParams.get("data") ?? "").slice(0, 512);
if (!data) {
sendJson(res, 400, { error: "missing data" });
return;
}
try {
const svg = qrSvg(data);
res.writeHead(200, {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(svg);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/config" && method === "GET") {
const profiles = configStore.profiles();
sendJson(res, 200, {
configFile: configStore.path,
keys: VALID_KEYS,
keys: UI_VALID_KEYS,
secretKeys: [...SECRET_KEYS],
enums: UI_ENUM_KEYS,
booleanKeys: [...UI_BOOLEAN_KEYS],
fieldDefaults: {
...UI_MODEL_DEFAULTS,
base_url: REGIONS.cn,
output_dir: defaultOutputBase(),
timeout: "300",
},
modelCatalog: UI_MODEL_CATALOG,
activeProfile: profiles.active,
default: profiles.default,
named: profiles.named,
@@ -130,6 +231,342 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
return;
}
if (path === "/api/skills" && method === "GET") {
sendJson(res, 200, { skills: listSkills() });
return;
}
if (path === "/api/skill" && method === "GET") {
const detail = getSkillDetail(u.searchParams.get("id") ?? "");
if (!detail) {
sendJson(res, 404, { error: "not found" });
return;
}
sendJson(res, 200, detail);
return;
}
if (path === "/api/skill/install" && method === "POST") {
const source = u.searchParams.get("source") ?? "";
const name = u.searchParams.get("name") ?? "";
try {
const buf = await readBodyBuffer(req, MAX_UPLOAD);
const result = installSkillZip(source, buf, name);
sendJson(res, 200, result);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/mcp" && method === "GET") {
sendJson(res, 200, { servers: listMcpServers() });
return;
}
if (path === "/api/mcp" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid JSON body" });
return;
}
const body = parsed as {
source?: unknown;
scope?: unknown;
name?: unknown;
config?: unknown;
};
const source = typeof body.source === "string" ? body.source : "";
const scope = typeof body.scope === "string" && body.scope ? body.scope : "global";
const name = typeof body.name === "string" ? body.name : "";
try {
writeMcpServer(source, scope, name, body.config);
sendJson(res, 200, { saved: name.trim() });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/mcp" && method === "DELETE") {
const source = u.searchParams.get("source") ?? "";
const scope = u.searchParams.get("scope") || "global";
const name = u.searchParams.get("name") ?? "";
try {
deleteMcpServer(source, scope, name);
sendJson(res, 200, { deleted: name });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/health" && method === "GET") {
const major = Number(process.versions.node.split(".")[0]);
sendJson(res, 200, {
node: process.version,
nodeOk: Number.isFinite(major) && major >= 18,
platform: process.platform,
cwd: process.cwd(),
});
return;
}
if (path === "/api/agents" && method === "GET") {
// Augment each agent with `launchable`: whether its CLI binary is on
// PATH. "Connected" only means bl is wired into the agent's config, so
// the UI uses this to avoid offering a launch that would instantly fail.
// `dispatchable` additionally requires a verified prompt contract.
const agents = listAgents();
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
sendJson(res, 200, {
agents: agents.map((a, i) => ({
...a,
launchable: launchable[i],
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
})),
});
return;
}
if (path === "/api/agent" && method === "GET") {
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
if (!detail) {
sendJson(res, 404, { error: "not found" });
return;
}
sendJson(res, 200, detail);
return;
}
if (path === "/api/agent/open" && method === "POST") {
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
const target = u.searchParams.get("path") ?? "";
const allowed = detail?.settings.some((s) => s.path === target) ?? false;
if (!detail || !allowed || !existsSync(target)) {
sendJson(res, 404, { error: "not found" });
return;
}
try {
await openPath(target);
sendJson(res, 200, { opened: target });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/scenarios" && method === "GET") {
// Curated Playground scenarios plus the connected agents that can be
// dispatched a prompt right now (on PATH + verified prompt contract).
const agents = listAgents();
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
const targets = agents
.map((a, i) => ({
id: a.id,
label: a.label,
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
}))
.filter((a) => a.dispatchable);
sendJson(res, 200, { scenarios: SCENARIOS, agents: targets });
return;
}
if (path === "/api/auth/status" && method === "GET") {
sendJson(
res,
200,
authBridge
? authBridge.status()
: {
authenticated: false,
methods: { apiKey: false, console: false, openapi: false },
primary: null,
},
);
return;
}
if (path === "/api/auth/login" && method === "POST") {
if (!authBridge) {
sendJson(res, 400, { error: "login unavailable" });
return;
}
authBridge.startConsoleLogin();
sendJson(res, 200, { started: true });
return;
}
if (path === "/api/auth/logout" && method === "POST") {
if (!authBridge) {
sendJson(res, 400, { error: "logout unavailable" });
return;
}
try {
const loggedOut = await authBridge.logout();
sendJson(res, 200, { loggedOut });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/assets" && method === "GET") {
sendJson(res, 200, listAssets(outputBase));
return;
}
if (path === "/api/asset/file" && method === "GET") {
const abs = resolveAssetPath(outputBase, u.searchParams.get("path") ?? "");
const st = abs && existsSync(abs) ? statSync(abs) : null;
if (!abs || !st || !st.isFile()) {
sendJson(res, 404, { error: "not found" });
return;
}
res.writeHead(200, {
"Content-Type": contentType(extname(abs)),
"Content-Length": st.size,
"Cache-Control": "no-store",
});
const stream = createReadStream(abs);
stream.on("error", () => {
if (!res.headersSent) res.writeHead(500);
res.end();
});
stream.pipe(res);
return;
}
if (path === "/api/asset" && method === "DELETE") {
const rel = u.searchParams.get("path") ?? "";
const abs = resolveAssetPath(outputBase, rel);
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
sendJson(res, 404, { error: "not found" });
return;
}
try {
unlinkSync(abs);
sendJson(res, 200, { deleted: rel });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/asset/open" && method === "POST") {
const rel = u.searchParams.get("path") ?? "";
const abs = resolveAssetPath(outputBase, rel);
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
sendJson(res, 404, { error: "not found" });
return;
}
try {
await openPath(abs);
sendJson(res, 200, { opened: rel });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/agent/launch" && method === "POST") {
try {
const result = await launchAgent(u.searchParams.get("id") ?? "");
sendJson(res, 200, result);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/agent/dispatch" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid JSON body" });
return;
}
const body = parsed as {
scenario?: unknown;
agent?: unknown;
values?: unknown;
custom?: unknown;
};
const agentId = typeof body.agent === "string" ? body.agent : "";
if (!agentSupportsPrompt(agentId)) {
sendJson(res, 400, { error: "agent cannot be dispatched a prompt" });
return;
}
let scenario: Scenario | undefined;
const custom = body.custom;
if (custom && typeof custom === "object" && !Array.isArray(custom)) {
const c = custom as { title?: unknown; prompt?: unknown; inputs?: unknown };
const promptTpl = typeof c.prompt === "string" ? c.prompt.trim() : "";
if (!promptTpl) {
sendJson(res, 400, { error: "custom scenario needs a prompt" });
return;
}
const inputs: { key: string; label: string }[] = [];
if (Array.isArray(c.inputs)) {
for (const it of c.inputs as unknown[]) {
if (it && typeof it === "object") {
const o = it as { key?: unknown; label?: unknown };
const key = typeof o.key === "string" ? o.key.trim() : "";
if (key) {
const label =
typeof o.label === "string" && o.label.trim() ? o.label.trim() : key;
inputs.push({ key, label });
}
}
}
}
scenario = {
id: "custom",
title: typeof c.title === "string" && c.title.trim() ? c.title.trim() : "Custom",
description: "",
category: "\u81ea\u5b9a\u4e49",
prompt: promptTpl,
inputs,
};
} else {
scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined;
}
if (!scenario) {
sendJson(res, 400, { error: "unknown scenario" });
return;
}
const values: Record<string, string> = {};
if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) {
for (const [k, v] of Object.entries(body.values as Record<string, unknown>)) {
if (typeof v === "string") values[k] = v;
}
}
for (const inp of scenario.inputs ?? []) {
if (!values[inp.key] || !values[inp.key]!.trim()) {
sendJson(res, 400, { error: `Missing input: ${inp.label}` });
return;
}
}
const prompt = renderScenarioPrompt(scenario, values);
try {
const result = await launchAgent(agentId, process.cwd(), prompt);
sendJson(res, 200, {
launched: true,
agent: agentId,
scenario: scenario.id,
command: result.command,
});
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/active" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
@@ -164,7 +601,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
return;
}
let normalized: string | undefined;
let cleaned: Record<string, string | number>;
let cleaned: Record<string, string | number | boolean>;
try {
normalized = normalizeConfigName(body.name);
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
@@ -191,9 +628,15 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found\n");
} catch {
if (!res.headersSent) res.writeHead(500);
res.end();
} catch (err) {
// Log server-side so failures are diagnosable, and return a JSON error
// instead of an empty 500 body.
console.error("[config ui] request failed:", err);
if (res.headersSent) {
res.end();
return;
}
sendJson(res, 500, { error: errMessage(err) });
}
});
}
@@ -217,9 +660,29 @@ export default defineCommand({
routes: [
"GET / -> web UI",
"GET /api/config -> read all profiles",
"GET /api/skills -> list installed agent skills",
"GET /api/skill -> read one skill's SKILL.md detail",
"POST /api/skill/install -> install a skill from an uploaded .zip into a skills root",
"GET /api/mcp -> list local MCP servers",
"POST /api/mcp -> create or update one MCP server (writes its source config)",
"DELETE /api/mcp -> remove one MCP server from its source config",
"GET /api/health -> runtime environment info (node, platform, cwd)",
"GET /api/agents -> list coding agent frameworks",
"GET /api/agent -> one agent's config detail (secrets masked)",
"POST /api/agent/open -> open one agent's config file with the OS default app",
"GET /api/auth/status -> current auth state",
"POST /api/auth/login -> start console login (opens browser)",
"POST /api/auth/logout -> clear all stored credentials",
"GET /api/assets -> list generated assets",
"GET /api/asset/file -> stream one asset file",
"POST /api/asset/open -> open one asset with the OS default app",
"POST /api/agent/launch -> launch a coding agent CLI in a new terminal",
"GET /api/scenarios -> list Playground scenarios and dispatchable agents",
"POST /api/agent/dispatch -> dispatch a scenario prompt to a connected agent",
"POST /api/profile -> save a profile",
"POST /api/active -> activate a profile",
"DELETE /api/profile -> delete a named profile",
"DELETE /api/asset -> delete one asset file",
],
},
format,
@@ -228,7 +691,8 @@ export default defineCommand({
}
const token = randomBytes(16).toString("hex");
const server = createConfigUiServer(token, ctx.configStore);
const outputBase = settings.outputDir || defaultOutputBase();
const server = createConfigUiServer(token, ctx.configStore, outputBase, makeAuthUiBridge(ctx));
let port: number;
try {
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const DELETE_FLAGS = {
fileId: {
@@ -30,6 +30,7 @@ export default defineCommand({
if (settings.quiet || format === "text") {
emitBare(`Deleted ${fileId}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const GET_FLAGS = {
fileId: {
@@ -46,7 +46,7 @@ export default defineCommand({
};
if (format === "json") {
emitResult(item, format);
emitResult({ ...item, request_id: response.request_id }, format);
return;
}
@@ -58,5 +58,6 @@ export default defineCommand({
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.description) emitBare(`description: ${item.description}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -55,7 +55,7 @@ export default defineCommand({
}));
if (format === "json") {
emitResult({ items, total }, format);
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
@@ -68,5 +68,6 @@ export default defineCommand({
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -9,10 +9,9 @@ import {
MAX_MEDIA_ZIP_BYTES,
BailianError,
ExitCode,
type DatasetFile,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const UPLOAD_FLAGS = {
file: {
@@ -135,17 +134,19 @@ export default defineCommand({
return;
}
const uploaded: DatasetFile = await uploadDataset(ctx.client, {
const uploaded = await uploadDataset(ctx.client, {
filePath,
purpose,
});
const { request_id, ...file } = uploaded;
if (settings.quiet) {
emitBare(uploaded.file_id);
emitBare(file.file_id);
} else if (format === "text") {
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
emitRequestId(request_id, settings.quiet);
} else {
emitResult(uploaded, format);
emitResult({ ...file, request_id }, format);
}
},
});
@@ -11,7 +11,7 @@ import {
type CommandContext,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const CREATE_FLAGS = {
model: {
@@ -163,6 +163,7 @@ async function runCreate(
emitBare(
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -7,7 +7,7 @@ import {
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const DELETE_FLAGS = {
deployedModel: {
@@ -71,6 +71,7 @@ export default defineCommand({
emitBare(deployedModel);
} else if (format === "text") {
emitBare(`Deleted ${deployedModel}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
+3 -2
View File
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const GET_FLAGS = {
deployedModel: {
@@ -58,7 +58,7 @@ export default defineCommand({
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
if (format === "json") {
emitResult(item, format);
emitResult({ ...item, request_id: response.request_id }, format);
return;
}
@@ -69,5 +69,6 @@ export default defineCommand({
const display = typeof value === "string" ? value : JSON.stringify(value);
emitBare(`${label(key)}${display}`);
}
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -4,7 +4,7 @@ import {
listDeployments,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -58,7 +58,7 @@ export default defineCommand({
}));
if (format === "json") {
emitResult({ items, total }, format);
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
@@ -78,5 +78,6 @@ export default defineCommand({
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -4,7 +4,7 @@ import {
listDeployableModels,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
const MODELS_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -122,7 +122,7 @@ export default defineCommand({
}
return out;
});
emitResult({ items, total }, format);
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
@@ -168,5 +168,6 @@ export default defineCommand({
]);
for (const line of formatTable(headers, rows)) emitBare(line);
if (total !== undefined) emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -4,7 +4,7 @@ import {
scaleDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const SCALE_FLAGS = {
deployedModel: {
@@ -72,6 +72,7 @@ export default defineCommand({
} else if (format === "text") {
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
emitBare(`Scaled ${deployedModel}${cap}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -4,7 +4,7 @@ import {
updateDeployment,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const UPDATE_FLAGS = {
deployedModel: {
@@ -70,6 +70,7 @@ export default defineCommand({
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
const summary = parts.length ? ` (${parts.join(", ")})` : "";
emitBare(`Updated ${deployedModel}${summary}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -23,7 +23,7 @@ export default defineCommand({
"--file photo.jpg --model qwen3-vl-plus",
"--file video.mp4 --model wan2.1-t2v-plus",
"--file audio.wav --model qwen3-asr-flash",
"--file cat.png --model qwen-image-2.0",
"--file cat.png --model qwen-image-3.0",
],
async run(ctx) {
const { settings, flags } = ctx;
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const CANCEL_FLAGS = {
jobId: {
@@ -38,6 +38,7 @@ export default defineCommand({
} else if (format === "text") {
const status = job?.status ? ` (status=${job.status})` : "";
emitBare(`Cancelled ${jobId}${status}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -4,7 +4,7 @@ import {
listCheckpoints,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
const CHECKPOINTS_FLAGS = {
jobId: {
@@ -47,7 +47,7 @@ export default defineCommand({
}));
if (format === "json") {
emitResult({ items, total }, format);
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
@@ -60,5 +60,6 @@ export default defineCommand({
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
for (const line of formatTable(headers, rows)) emitBare(line);
emitBare(`\nTotal: ${total}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -27,7 +27,7 @@ import {
} from "bailian-cli-core";
import { existsSync, statSync } from "fs";
import { basename } from "path";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
/**
* A `--datasets` / `--validations` token is treated as a local file to upload
@@ -631,6 +631,7 @@ async function runCreate<F extends FlagsDef>(
if (job?.job_id) {
emitBare(`Created fine-tune job: ${job.job_id}`);
if (job.status) emitBare(`Status: ${job.status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const DELETE_FLAGS = {
jobId: {
@@ -36,6 +36,7 @@ export default defineCommand({
emitBare(jobId);
} else if (format === "text") {
emitBare(`Deleted ${jobId}.`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -4,7 +4,7 @@ import {
exportCheckpoint,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const EXPORT_FLAGS = {
jobId: {
@@ -69,6 +69,7 @@ export default defineCommand({
emitBare(
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const GET_FLAGS = {
jobId: {
@@ -56,7 +56,7 @@ export default defineCommand({
};
if (format === "json") {
emitResult(item, format);
emitResult({ ...item, request_id: response.request_id }, format);
return;
}
@@ -76,5 +76,6 @@ export default defineCommand({
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -1,5 +1,5 @@
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
const LIST_FLAGS = {
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
@@ -48,7 +48,7 @@ export default defineCommand({
}));
if (format === "json") {
emitResult({ items, total }, format);
emitResult({ items, total, request_id: response.request_id }, format);
return;
}
@@ -78,5 +78,6 @@ export default defineCommand({
emitBare(
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
);
emitRequestId(response.request_id, settings.quiet);
},
});
@@ -6,7 +6,7 @@ import {
type FineTuneLogEntry,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
/**
* Render a single log entry as a single line (mirrors the flatten logic used
@@ -187,6 +187,7 @@ export default defineCommand({
emitBare(renderEntry(entry));
}
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
emitRequestId(response.request_id, settings.quiet);
} else {
emitResult(response, format);
}
@@ -6,7 +6,7 @@ import {
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
const DEFAULT_INTERVAL_SEC = 10;
const MIN_INTERVAL_SEC = 1;
@@ -135,9 +135,13 @@ export default defineCommand({
} else if (format === "text") {
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
if (status === "SUCCEEDED") emitBare(`${jobId} ${status}`);
emitRequestId(response.request_id, settings.quiet);
} else {
// json: a compact, purpose-built status probe.
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
emitResult(
{ job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id },
format,
);
}
if (terminal && status !== "SUCCEEDED") {
@@ -175,6 +179,7 @@ export default defineCommand({
emitResult(response, format);
} else if (status === "SUCCEEDED") {
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
emitRequestId(response.request_id, settings.quiet);
}
if (status !== "SUCCEEDED") {
throw new BailianError(
+2 -2
View File
@@ -47,7 +47,7 @@ const EDIT_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (default: qwen-image-2.0)",
description: "Model ID (default: qwen-image-3.0)",
},
size: {
type: "string",
@@ -123,7 +123,7 @@ export default defineCommand({
}
const prompt = flags.prompt;
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
const route = resolveImageEditApi(model);
// Auto-upload local files (resolve all images in parallel)
@@ -35,7 +35,7 @@ const GENERATE_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: "Model ID (default: qwen-image-2.0)",
description: "Model ID (default: qwen-image-3.0)",
},
size: {
type: "string",
@@ -105,7 +105,7 @@ export default defineCommand({
const { settings, flags } = ctx;
const prompt = flags.prompt;
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
const route = resolveImageGenerateApi(model);
const defaultSize = "1:1";
const sizeInput = flags.size || defaultSize;
@@ -25,7 +25,7 @@ const PROVIDER_BLOCKS: Record<string, string> = {
};
const SINGLE_MODEL: Record<string, string> = {
bailian: ` model: qwen3.7-max`,
bailian: ` model: qwen3.8-max`,
claude: ` model: claude-sonnet-4-6`,
qoder: ` model: ultimate`,
ark: ` model: doubao-seed-2-1-pro-260628`,
@@ -39,7 +39,7 @@ function buildTemplate(options: { provider: string; agentName: string }): string
const modelBlock =
options.provider === "all"
? ` model:\n bailian: qwen3.7-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628`
? ` model:\n bailian: qwen3.8-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628`
: SINGLE_MODEL[options.provider]!;
const toolBlock =
@@ -22,11 +22,15 @@ export function listenLocalServer(server: http.Server, port = 0): Promise<number
});
}
/** Open a URL in the user's default browser (best-effort, cross-platform). */
export function openInBrowser(url: string): Promise<void> {
/**
* Open a local file, directory, or URL with the OS default handler
* (best-effort, cross-platform). Arguments are passed to `execFile` as an array
* so the target is never interpreted by a shell.
*/
export function openPath(target: string): Promise<void> {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
const args = platform === "win32" ? ["/c", "start", "", target] : [target];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
@@ -35,3 +39,8 @@ export function openInBrowser(url: string): Promise<void> {
});
});
}
/** Open a URL in the user's default browser (best-effort, cross-platform). */
export function openInBrowser(url: string): Promise<void> {
return openPath(url);
}
+115
View File
@@ -0,0 +1,115 @@
import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
installSkillWithFanout,
parseSkillNames,
readSkillLock,
runWithConcurrency,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
interface AddOutcome {
name: string;
status: "installed" | "failed";
publishedAt?: string;
agents?: string[];
reason?: string;
}
/** Max number of skills downloading/installing at the same time. */
const INSTALL_CONCURRENCY = 3;
export default defineCommand({
description: "Install skills from the Bailian skill registry into local agents",
auth: "none",
usageArgs: "--name <all|name,...>",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
description: "Skills to install: all or comma-separated skill names",
required: true,
},
},
exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, false);
const index = await fetchSkillsIndex();
const remoteNames = Object.keys(index.skills);
const names = requested === "all" ? remoteNames : requested;
const lock = readSkillLock();
const agents = detectInstalledAgents();
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
// Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key.
const tasks = names.map((name) => async (): Promise<AddOutcome> => {
const entry = index.skills[name];
if (!entry) {
return { name, status: "failed", reason: "skill not found in registry" };
}
try {
const record = await installSkillWithFanout(
name,
entry,
agents,
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return {
name,
status: "installed",
publishedAt: entry.publishedAt,
agents: record.linkedAgents,
};
} catch (err) {
return {
name,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
};
}
});
const results = await runWithConcurrency(tasks, INSTALL_CONCURRENCY);
writeSkillLock(lock);
if (format === "json") {
emitResult(
{
registry: getSkillRegistryBaseUrl(),
agents: agents.map((agent) => agent.id),
skills: results,
},
format,
);
} else if (results.length === 0) {
emitBare("Skill registry is empty; no skills to install.");
} else {
const rows = results.map((result) => [
result.name,
result.status,
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"),
]);
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
emitBare(line);
}
}
const failed = results.filter((result) => result.status === "failed");
if (failed.length > 0) {
throw new BailianError(
`${failed.length}/${results.length} skill(s) failed to install`,
ExitCode.GENERAL,
"Check the reason for failed skills in the output; network failures can be retried with bl skill add",
);
}
},
});
@@ -0,0 +1,58 @@
import {
defineCommand,
detectOutputFormat,
computeSkillStatuses,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
listSkillDirsOnDisk,
readSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
const DESCRIPTION_MAX = 60;
function truncate(text: string | undefined): string {
if (!text) return "-";
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1)}` : text;
}
export default defineCommand({
description: "List registry skills and diff against local installs",
auth: "none",
exampleArgs: ["", "--output json"],
notes: [
"STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)",
],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
// Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk
const index = await fetchSkillsIndex();
const lock = readSkillLock();
const rows = computeSkillStatuses(index, lock, listSkillDirsOnDisk());
if (format === "json") {
emitResult(
{
registry: getSkillRegistryBaseUrl(),
...(index.updatedAt ? { updatedAt: index.updatedAt } : {}),
skills: rows,
},
format,
);
return;
}
if (rows.length === 0) {
emitBare("Skill registry is empty and no skills are installed locally.");
return;
}
const table = rows.map((row) => [
row.name,
row.status,
row.publishedAt ? row.publishedAt.slice(0, 19).replace("T", " ") : "-",
truncate(row.description),
]);
for (const line of formatTable(["NAME", "STATUS", "UPDATEDAT", "DESCRIPTION"], table)) {
emitBare(line);
}
},
});
@@ -0,0 +1,100 @@
import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
listSkillDirsOnDisk,
parseSkillNames,
readSkillLock,
removeSkillDir,
unlinkSkillFromAgents,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
interface RemoveOutcome {
name: string;
status: "removed" | "failed";
removedLinks?: number;
reason?: string;
}
export default defineCommand({
description: "Remove locally installed skills (registry is untouched)",
auth: "none",
usageArgs: "--name <all|name,...>",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
description: "Skills to remove: all or comma-separated skill names",
required: true,
},
},
exampleArgs: ["--name spark-video", "--name all"],
async run(ctx) {
// Purely local operation: no remote access, works offline
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, false);
const lock = readSkillLock();
const names = requested === "all" ? Object.keys(lock.skills) : requested;
if (names.length === 0) {
emitResult({ skills: [] }, format);
if (format === "text") emitBare("No skills installed locally; nothing to remove.");
return;
}
const diskDirs = new Set(listSkillDirsOnDisk());
const results: RemoveOutcome[] = [];
for (const name of names) {
const locked = lock.skills[name];
if (!locked) {
results.push({
name,
status: "failed",
reason: diskDirs.has(name)
? "directory not managed by bl skill (untracked); remove manually if needed"
: "not installed",
});
continue;
}
try {
// Reclaim agent fan-out first, then delete canonical, finally clear the lock entry
const removedLinks = unlinkSkillFromAgents(name, locked.links ?? []);
removeSkillDir(name);
delete lock.skills[name];
results.push({ name, status: "removed", removedLinks: removedLinks.length });
} catch (err) {
results.push({
name,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
});
}
}
writeSkillLock(lock);
if (format === "json") {
emitResult({ skills: results }, format);
} else {
const rows = results.map((r) => [
r.name,
r.status,
r.status === "removed" ? `reclaimed ${r.removedLinks} agent link(s)` : (r.reason ?? "-"),
]);
for (const line of formatTable(["NAME", "STATUS", "DETAIL"], rows)) {
emitBare(line);
}
}
const failed = results.filter((r) => r.status === "failed");
if (failed.length > 0) {
throw new BailianError(
`${failed.length}/${results.length} skill(s) failed to remove`,
ExitCode.GENERAL,
"Check the reason for failed skills in the output; use bl skill list to verify local install status",
);
}
},
});
@@ -0,0 +1,151 @@
import {
BailianError,
ExitCode,
defineCommand,
detectOutputFormat,
detectInstalledAgents,
fanOutSkillToAgents,
fetchSkillsIndex,
getSkillRegistryBaseUrl,
installSkillWithFanout,
listSkillDirsOnDisk,
parseSkillNames,
readSkillLock,
runWithConcurrency,
writeSkillLock,
} from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
interface UpdateOutcome {
name: string;
status: "updated" | "up-to-date" | "skipped" | "failed";
publishedAt?: string;
reason?: string;
}
/** Max number of skills downloading/installing at the same time. */
const UPDATE_CONCURRENCY = 3;
export default defineCommand({
description: "Update installed skills to the latest registry versions",
auth: "none",
usageArgs: "[--name <all|name,...>]",
flags: {
name: {
type: "string",
valueHint: "<all|name,...>",
description:
"Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills)",
},
},
exampleArgs: ["", "--name spark-video"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
const requested = parseSkillNames(ctx.flags.name, true);
const index = await fetchSkillsIndex();
const lock = readSkillLock();
const disk = new Set(listSkillDirsOnDisk());
const agents = detectInstalledAgents();
const results: UpdateOutcome[] = [];
const targets: string[] = [];
if (requested === "all") {
// Default: only process skills already installed in lock; reinstall only if version changed or local dir is missing
for (const [name, locked] of Object.entries(lock.skills)) {
const entry = index.skills[name];
if (!entry) {
results.push({
name,
status: "skipped",
reason: "delisted from remote; local copy retained",
});
continue;
}
if (entry.contentHash === locked.contentHash && disk.has(name)) {
// Self-healing: content unchanged, but still fill fan-out links for agents
// detected since the last install (and refresh recorded copies); the merged
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []);
lock.skills[name] = { ...locked, links: fanout.links };
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
continue;
}
targets.push(name);
}
} else {
// Explicit names: only update skills that are already installed; reject uninstalled ones
for (const name of requested) {
if (!lock.skills[name]) {
results.push({
name,
status: "failed",
reason: "not installed; run bl skill add --name " + name + " first",
});
continue;
}
targets.push(name);
}
}
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
const entry = index.skills[name];
if (!entry) {
return { name, status: "failed", reason: "skill not found in registry" };
}
try {
const record = await installSkillWithFanout(
name,
entry,
agents,
lock.skills[name]?.links ?? [],
);
lock.skills[name] = record.lockEntry;
return { name, status: "updated", publishedAt: entry.publishedAt };
} catch (err) {
return {
name,
status: "failed",
reason: err instanceof Error ? err.message : String(err),
};
}
});
const updateResults = await runWithConcurrency(tasks, UPDATE_CONCURRENCY);
results.push(...updateResults);
writeSkillLock(lock);
if (format === "json") {
emitResult({ registry: getSkillRegistryBaseUrl(), skills: results }, format);
} else if (results.length === 0) {
emitBare("No skills installed locally; run bl skill add first.");
} else {
const rows = results.map((result) => [
result.name,
result.status,
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
]);
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED"], rows)) {
emitBare(line);
}
// Footnotes for skipped / failed entries
const annotated = results.filter(
(result) => (result.status === "skipped" || result.status === "failed") && result.reason,
);
if (annotated.length > 0) {
emitBare("");
for (const result of annotated) {
emitBare(` ${result.name}: ${result.reason}`);
}
}
}
const failed = results.filter((result) => result.status === "failed");
if (failed.length > 0) {
throw new BailianError(
`${failed.length} skill(s) failed to update`,
ExitCode.GENERAL,
"Check the reason for failed skills in the output; network failures can be retried with bl skill update",
);
}
},
});
@@ -9,7 +9,6 @@ import {
type DashScopeASRRequest,
type DashScopeASRTaskResult,
type DashScopeAsyncResponse,
trackingHeaders,
stripUndefined,
taskPath,
speechRecognizePath,
@@ -201,9 +200,7 @@ async function handleAsyncMode(
}
// Fetch transcription JSON
const transRes = await fetch(subResult.transcription_url, {
headers: trackingHeaders(),
});
const transRes = await fetch(subResult.transcription_url);
if (!transRes.ok) {
throw new BailianError(
`Failed to download transcription: HTTP ${transRes.status}`,
+2 -2
View File
@@ -15,7 +15,7 @@ import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
import { readFileSync } from "fs";
const CHAT_FLAGS = {
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.7-max)" },
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.8-max)" },
message: {
type: "array",
valueHint: "<text>",
@@ -123,7 +123,7 @@ export default defineCommand({
const { settings, flags } = ctx;
const { system, messages } = parseMessages(flags);
const model = flags.model || settings.defaultTextModel || "qwen3.7-max";
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
const shouldStream = flags.stream || process.stdout.isTTY;
const format = detectOutputFormat(settings.output);
+127 -34
View File
@@ -1,22 +1,31 @@
import { execSync } from "child_process";
import { writeFileSync } from "fs";
import { join } from "path";
import { defineCommand, getConfigDir } from "bailian-cli-core";
import { ansi, fetchLatestVersion, type AnsiStyles } from "bailian-cli-runtime";
import {
BailianError,
DEFAULT_INSTALL_PS1_URL,
DEFAULT_INSTALL_SCRIPT_URL,
defineCommand,
getConfigDir,
getUpdateInstallMethod,
type InstallMethod,
} from "bailian-cli-core";
import {
ansi,
fetchLatestVersion,
fetchBinaryChannelVersion,
isValidUpdateTargetVersion,
normalizeBinaryVersion,
performBinaryUpdate,
type AnsiStyles,
} from "bailian-cli-runtime";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
/** Build the install command for the given npm package. */
function detectInstallCommand(npmPackage: string): { cmd: string; label: string } {
return { cmd: `npm install -g ${npmPackage}@latest`, label: "npm" };
}
function updateAgentSkill(color: AnsiStyles): void {
process.stderr.write("\nUpdating agent skill...\n");
try {
// Reinstall (not `skills update`) into ~/.agents/skills/ and sync to all agent apps.
// `--all` on `skills add` means --skill '*' --agent '*' -y (Cursor, Claude Code, etc.).
execSync(SKILL_INSTALL_CMD, { stdio: "inherit" });
process.stderr.write(`${color.green("\u2713 Agent skill updated.")}\n`);
} catch {
@@ -26,56 +35,140 @@ function updateAgentSkill(color: AnsiStyles): void {
}
}
function writeUpdateState(version: string): void {
try {
const stateFile = join(getConfigDir(), "update-state.json");
writeFileSync(stateFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: version }));
} catch {
/* ignore */
}
}
async function resolveLatest(method: InstallMethod, npmPackage: string): Promise<string | null> {
if (method === "binary") {
return (
(await fetchBinaryChannelVersion("latest", 5000)) ??
(await fetchLatestVersion(5000, npmPackage))
);
}
return fetchLatestVersion(5000, npmPackage);
}
function binaryReinstallHint(): string {
if (process.platform === "win32") {
return ` irm ${DEFAULT_INSTALL_PS1_URL} | iex\n`;
}
return ` curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash\n`;
}
export default defineCommand({
description: "Update the CLI to the latest version",
description: "Update the CLI to the latest or a specified version",
auth: "none",
exampleArgs: [""],
usageArgs: "[--to <version>]",
flags: {
to: {
type: "string",
valueHint: "<version>",
description: "Install this exact version instead of the latest",
},
},
exampleArgs: ["", "--to 0.1.14"],
validate(flags) {
if (flags.to === undefined) return undefined;
if (!flags.to.trim()) return "--to requires a non-empty version";
if (!isValidUpdateTargetVersion(flags.to)) {
return `--to must be a semver version (e.g. 1.13.0, v1.13.0, 0.0.0-beta-<sha>-<YYYYMMDDHHMM>), got: ${flags.to.trim()}`;
}
return undefined;
},
async run(ctx) {
const { identity } = ctx;
const npmPackage = identity.npmPackage;
const binName = identity.binName;
const currentVersion = identity.version;
const color = ansi(process.stderr);
const method = getUpdateInstallMethod(identity);
const requestedTo = ctx.flags.to?.trim();
const pinnedVersion = requestedTo ? normalizeBinaryVersion(requestedTo) : undefined;
process.stderr.write(`Current version: ${color.yellow(currentVersion)}\n`);
process.stderr.write(`Install method: ${color.dim(method)}\n`);
if (pinnedVersion) {
process.stderr.write(`Target version: ${color.green(pinnedVersion)}\n`);
} else {
process.stderr.write("Checking for updates...\n");
}
// Check latest version first
process.stderr.write("Checking for updates...\n");
const latest = await fetchLatestVersion(5000, npmPackage);
if (latest && latest === currentVersion) {
process.stderr.write(`${color.green(`\u2713 Already up to date (${currentVersion}).`)}\n`);
updateAgentSkill(color);
if (method === "brew" || method === "winget") {
const cmd =
method === "brew" ? "brew upgrade bailian-cli" : "winget upgrade Aliyun.BailianCLI";
process.stderr.write(
`${color.yellow(`This CLI was installed via ${method}. Update with:`)}\n ${cmd}\n`,
);
if (pinnedVersion) {
process.stderr.write(
`${color.dim(`Note: --to is not supported for ${method} installs.`)}\n`,
);
}
return;
}
if (latest) {
process.stderr.write(`Latest version: ${color.green(latest)}\n\n`);
const targetVersion = pinnedVersion ?? (await resolveLatest(method, npmPackage));
if (!targetVersion) {
process.stderr.write(`${color.yellow("Could not determine the latest version.")}\n`);
return;
}
const { cmd, label } = detectInstallCommand(npmPackage);
process.stderr.write(`Updating ${npmPackage} via ${label}...\n\n`);
if (targetVersion === currentVersion) {
const message = pinnedVersion
? `\u2713 Already at ${currentVersion}.`
: `\u2713 Already up to date (${currentVersion}).`;
process.stderr.write(`${color.green(message)}\n`);
if (method === "npm") updateAgentSkill(color);
return;
}
if (!pinnedVersion) {
process.stderr.write(`Latest version: ${color.green(targetVersion)}\n\n`);
} else {
process.stderr.write("\n");
}
if (method === "binary") {
process.stderr.write(`Updating via binary channel...\n\n`);
try {
const newVer = await performBinaryUpdate(targetVersion);
process.stderr.write(
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
);
writeUpdateState(newVer);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const reinstall =
error instanceof BailianError && error.hint
? error.hint.replace(/^Re-run:\s*/i, "")
: binaryReinstallHint().trim();
process.stderr.write(`\nAutomatic binary update failed: ${message}\n`);
process.stderr.write("Re-run the install script:\n");
process.stderr.write(` ${reinstall}\n\n`);
}
return;
}
const npmSpec = pinnedVersion ? `${npmPackage}@${pinnedVersion}` : `${npmPackage}@latest`;
const cmd = `npm install -g ${npmSpec}`;
process.stderr.write(`Updating ${npmPackage} via npm...\n\n`);
try {
execSync(cmd, { stdio: "inherit" });
// Verify the installed version after update
try {
const rawVer = execSync(`${binName} --version 2>/dev/null`, { encoding: "utf-8" }).trim();
// `<bin> --version` outputs "<bin> X.Y.Z" — extract just the version number
const newVer = rawVer.replace(new RegExp(`^${binName}\\s+`), "");
process.stderr.write(
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\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 */
}
writeUpdateState(newVer);
} catch {
process.stderr.write(`\n${color.green("\u2713 Update complete.")}\n`);
}
+12
View File
@@ -91,6 +91,14 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as assetList } from "./commands/asset-center/list.ts";
export { default as assetGet } from "./commands/asset-center/get.ts";
export { default as assetFavorite } from "./commands/asset-center/favorite.ts";
export { default as assetUnfavorite } from "./commands/asset-center/unfavorite.ts";
export { default as assetDelete } from "./commands/asset-center/delete.ts";
export { default as assetDownload } from "./commands/asset-center/download.ts";
export { default as assetStats } from "./commands/asset-center/stats.ts";
export { default as assetStorage } from "./commands/asset-center/storage.ts";
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
@@ -113,3 +121,7 @@ export { default as pluginInstall } from "./commands/plugin/install.ts";
export { default as pluginLink } from "./commands/plugin/link.ts";
export { default as pluginList } from "./commands/plugin/list.ts";
export { default as pluginRemove } from "./commands/plugin/remove.ts";
export { default as skillAdd } from "./commands/skill/add.ts";
export { default as skillUpdate } from "./commands/skill/update.ts";
export { default as skillRemove } from "./commands/skill/remove.ts";
export { default as skillList } from "./commands/skill/list.ts";
@@ -0,0 +1,31 @@
import { expect, test } from "vite-plus/test";
import {
AGENT_COMMANDS,
agentCommand,
agentLaunchable,
launchAgent,
} from "../src/commands/config/agent-launch.ts";
test("agentCommand 返回已知 agent 的可执行命令,未知返回 undefined", () => {
expect(agentCommand("qwen-code")).toBe("qwen");
expect(agentCommand("codex")).toBe("codex");
expect(agentCommand("nope")).toBeUndefined();
// Guards against prototype keys leaking through the allowlist lookup.
expect(agentCommand("toString")).toBeUndefined();
});
test("AGENT_COMMANDS 覆盖所有已知 agent id", () => {
expect(Object.keys(AGENT_COMMANDS).sort()).toEqual(
["claude-code", "codex", "hermes", "opencode", "openclaw", "qwen-code"].sort(),
);
});
test("launchAgent 对未知 id 抛错且不启动任何进程", async () => {
await expect(launchAgent("definitely-not-an-agent")).rejects.toThrow(/Unknown agent/);
});
test("agentLaunchable 对未知 id 返回 false,不探测 PATH", async () => {
expect(await agentLaunchable("definitely-not-an-agent")).toBe(false);
// Prototype keys must not resolve to a launchable command either.
expect(await agentLaunchable("toString")).toBe(false);
});
+96
View File
@@ -0,0 +1,96 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, sep } from "node:path";
import { expect, test } from "vite-plus/test";
import { listAssets, resolveAssetPath, contentType } from "../src/commands/config/assets.ts";
/** Build an isolated temp output base and clean it up afterwards. */
function withBase(fn: (base: string) => void): void {
const base = mkdtempSync(join(tmpdir(), "bl-assets-"));
try {
fn(base);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
function put(base: string, rel: string, content = "x"): string {
const path = join(base, rel);
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, content);
return path;
}
test("listAssets 按分类归类并识别类型", () => {
withBase((base) => {
put(base, "images/a.png");
put(base, "videos/clip.mp4");
put(base, "speech/voice.mp3");
put(base, "notes.txt"); // loose file -> other
const { base: reported, assets } = listAssets(base);
expect(reported).toBe(base);
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image", ext: "png" });
expect(byName["clip.mp4"]).toMatchObject({ category: "videos", kind: "video", ext: "mp4" });
expect(byName["voice.mp3"]).toMatchObject({ category: "speech", kind: "audio", ext: "mp3" });
expect(byName["notes.txt"]).toMatchObject({ category: "other", kind: "other" });
});
});
test("listAssets 递归扫描任意子文件夹(非预设分类目录)", () => {
withBase((base) => {
put(base, "news-articles/report.md");
put(base, "custom/deep/nested/pic.png");
put(base, "images/a.png");
const { assets } = listAssets(base);
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
// Arbitrary top-level folder becomes the category.
expect(byName["report.md"]).toMatchObject({
category: "news-articles",
kind: "other",
ext: "md",
});
// Deeply nested file is discovered; category is its top-level folder.
expect(byName["pic.png"]).toMatchObject({ category: "custom", kind: "image" });
expect(byName["pic.png"]!.relPath).toBe(join("custom", "deep", "nested", "pic.png"));
// Known category dirs still work.
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image" });
});
});
test("listAssets 按生成时间倒序排列", () => {
withBase((base) => {
const older = put(base, "images/old.png");
const newer = put(base, "images/new.png");
// Force a stable ordering by stamping mtimes.
utimesSync(older, new Date(1000), new Date(1000));
utimesSync(newer, new Date(2000), new Date(2000));
const { assets } = listAssets(base);
expect(assets.map((a) => a.name)).toEqual(["new.png", "old.png"]);
});
});
test("listAssets 目录不存在时返回空", () => {
const { assets } = listAssets(join(tmpdir(), "bl-assets-does-not-exist-xyz"));
expect(assets).toEqual([]);
});
test("resolveAssetPath 阻止目录穿越", () => {
withBase((base) => {
put(base, "images/a.png");
expect(resolveAssetPath(base, "images/a.png")).toBe(join(base, "images/a.png"));
expect(resolveAssetPath(base, "../../etc/passwd")).toBeNull();
expect(resolveAssetPath(base, "")).toBeNull();
expect(resolveAssetPath(base, "images" + sep + ".." + sep + ".." + sep + "outside")).toBeNull();
});
});
test("contentType 映射常见扩展名", () => {
expect(contentType(".png")).toBe("image/png");
expect(contentType(".MP4")).toBe("video/mp4");
expect(contentType(".mp3")).toBe("audio/mpeg");
expect(contentType(".xyz")).toBe("application/octet-stream");
});
@@ -0,0 +1,71 @@
import { expect, test } from "vite-plus/test";
import { makeAuthUiBridge } from "../src/commands/auth/console-ui.ts";
import type { AuthState, AuthStore, Identity, Settings } from "bailian-cli-core";
/** Build a bridge over a fake AuthStore. Only describe()/logout() are used by
* the surface under test; startConsoleLogin() is intentionally not exercised
* (it opens a browser and starts a real callback server). */
function bridgeWith(state: AuthState, onLogout?: (scope: string) => Promise<boolean>) {
const authStore = {
describe: () => state,
stored: () => ({ apiKey: false, console: false, openapi: false }),
resolveBaseUrl: () => "https://dashscope.aliyuncs.com",
login: async () => {},
logout: onLogout ?? (async () => false),
path: "/tmp/config.json",
} as unknown as AuthStore;
return makeAuthUiBridge({
identity: {} as unknown as Identity,
settings: {} as unknown as Settings,
authStore,
});
}
test("status: console 凭证 -> primary=console带 region/site 与掩码 token", () => {
const st = bridgeWith({
console: {
token: "abcd1234efgh5678",
region: "cn-beijing",
site: "domestic",
source: "config",
},
}).status();
expect(st.authenticated).toBe(true);
expect(st.primary).toBe("console");
expect(st.methods).toEqual({ apiKey: false, console: true, openapi: false });
expect(st.region).toBe("cn-beijing");
expect(st.site).toBe("domestic");
expect(st.masked).toContain("...");
// Masked, never the raw token.
expect(st.masked).not.toBe("abcd1234efgh5678");
});
test("status: 无任何凭证 -> 未认证,无 masked", () => {
const st = bridgeWith({}).status();
expect(st.authenticated).toBe(false);
expect(st.primary).toBe(null);
expect(st.masked).toBeUndefined();
expect(st.methods).toEqual({ apiKey: false, console: false, openapi: false });
});
test("status: 仅 apiKey -> primary=apiKey", () => {
const st = bridgeWith({
apiKey: { token: "sk-1234567890", baseUrl: "https://dashscope.aliyuncs.com", source: "env" },
}).status();
expect(st.primary).toBe("apiKey");
expect(st.methods.apiKey).toBe(true);
expect(st.region).toBeUndefined();
});
test("logout 委托给 authStore.logout('all')", async () => {
let scope = "";
const bridge = bridgeWith(
{ apiKey: { token: "sk-x", baseUrl: "https://x", source: "config" } },
async (s) => {
scope = s;
return true;
},
);
expect(await bridge.logout()).toBe(true);
expect(scope).toBe("all");
});
+58 -23
View File
@@ -83,6 +83,43 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
expect(res.json.secretKeys).toContain("api_key");
expect(res.json.keys).toContain("default_image_to_video_model");
expect(res.json.keys).toContain("default_reference_to_video_model");
// Console/telemetry fields are editable via the UI (full ConfigFile surface).
expect(res.json.keys).toContain("console_site");
expect(res.json.keys).toContain("telemetry");
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
expect(res.json.booleanKeys).toContain("telemetry");
// Default field hints are surfaced as prefilled values in the UI.
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
expect(res.json.fieldDefaults.output_dir).toContain("bailian-output");
expect(res.json.fieldDefaults.timeout).toBe("300");
expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com");
// Per-category model catalog (click-to-fill suggestions) is exposed too.
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-3.0" });
expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain(
"happyhorse-1.1-i2v",
);
expect(res.json.modelCatalog.default_speech_model.map((m: { id: string }) => m.id)).toContain(
"fun-asr",
);
});
});
test("GET /api/auth/status 无 bridge 时返回未认证login/logout 返回 400", async () => {
await withServer(async (port) => {
// The test harness builds the server without an auth bridge, so the auth
// endpoints degrade safely instead of throwing.
const status = await httpJson(port, "GET", `/api/auth/status?token=${TOKEN}`);
expect(status.status).toBe(200);
expect(status.json.authenticated).toBe(false);
expect(status.json.methods).toEqual({ apiKey: false, console: false, openapi: false });
expect(status.json.primary).toBe(null);
const login = await httpJson(port, "POST", `/api/auth/login?token=${TOKEN}`);
expect(login.status).toBe(400);
const logout = await httpJson(port, "POST", `/api/auth/logout?token=${TOKEN}`);
expect(logout.status).toBe(400);
});
});
@@ -130,44 +167,42 @@ test("POST /api/profile 写命名 profiletimeout 强制为 number空串
});
});
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
test("POST /api/profile 可编辑 console/telemetry 字段并按类型持久化", async () => {
await withServer(async (port) => {
await writeConfigFile(
{
api_key: "sk-old",
output: "json",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
},
"stage",
);
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "sk-new" } },
body: {
name: "stage",
data: {
api_key: "sk-stage",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: "42",
telemetry: "false",
},
},
});
expect(save.status).toBe(200);
const profile = readConfigFile("stage");
expect(profile).toMatchObject({
api_key: "sk-new",
api_key: "sk-stage",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
});
expect(profile.output).toBeUndefined();
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage).toMatchObject({
api_key: "sk-new",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
// Coerced to the right JSON types, not left as strings.
expect(rawConfig.stage.console_switch_agent).toBe(42);
expect(rawConfig.stage.telemetry).toBe(false);
// Invalid enum value is rejected.
const bad = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { console_site: "mars" } },
});
expect(rawConfig.stage.output).toBeUndefined();
expect(bad.status).toBe(400);
expect(String(bad.json.error)).toMatch(/console_site/);
});
});
+4 -4
View File
@@ -217,7 +217,7 @@ describe("e2e: auth", () => {
authorization: "Bearer sk-e2e-placeholder",
sourceConfig: expect.any(String),
body: {
model: "qwen3.7-max",
model: "qwen3.8-max",
stream: false,
},
});
@@ -266,7 +266,7 @@ describe("e2e: auth", () => {
expect(config["token-plan"]).toMatchObject({
api_key: "sk-sp-e2e-placeholder",
base_url: validationServer.baseUrl,
default_text_model: "qwen3.8-max-preview",
default_text_model: "qwen3.8-max",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "happyhorse-1.1-i2v",
default_reference_to_video_model: "happyhorse-1.1-r2v",
@@ -314,7 +314,7 @@ describe("e2e: auth", () => {
authorization: "Bearer sk-sp-e2e-placeholder",
sourceConfig: expect.any(String),
body: {
model: "qwen3.7-max",
model: "qwen3.8-max",
stream: false,
},
});
@@ -328,7 +328,7 @@ describe("e2e: auth", () => {
expect(config["token-plan"]).toMatchObject({
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_text_model: "qwen3.8-max-preview",
default_text_model: "qwen3.8-max",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "happyhorse-1.1-i2v",
default_reference_to_video_model: "happyhorse-1.1-r2v",
@@ -21,7 +21,7 @@ agents:
assistant:
description: "E2E multi-provider fixture"
model:
bailian: qwen3.7-max
bailian: qwen3.8-max
claude: claude-sonnet-4-6
instructions: |
You are a helpful assistant.
@@ -194,13 +194,13 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("【qwen-image-2.0】图片编辑", async () => {
test("【qwen-image-3.0】图片编辑", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const gen = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"qwen-image-2.0",
"qwen-image-3.0",
"--prompt",
"一只简笔画小猫,白底",
"--out-dir",
@@ -220,7 +220,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
"image",
"edit",
"--model",
"qwen-image-2.0",
"qwen-image-3.0",
"--image",
imagePath!,
"--prompt",
@@ -202,19 +202,19 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"image",
"generate",
"--model",
"qwen-image-2.0",
"qwen-image-3.0",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prompt|Usage:/i);
});
test("【qwen-image-2.0】图片生成", async () => {
test("【qwen-image-3.0】图片生成", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
"image",
"generate",
"--model",
"qwen-image-2.0",
"qwen-image-3.0",
"--prompt",
"一只简笔画小猫,白底",
"--out-dir",
@@ -0,0 +1,140 @@
import { existsSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { isBailianE2EEnabled, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { SKILL_ROUTES } from "./topic-routes.ts";
/** Canonical always-published skill; also the backbone of advisor wiki sync */
const WIKI_SKILL = "bailian-docs-llm-wiki";
/** Redirect ~/.bailian into a throwaway dir so lock/skill writes never touch the real user config */
function makeTempConfigDir(): string {
return mkdtempSync(join(tmpdir(), "bl-skill-e2e-"));
}
describe("e2e: skill", () => {
test("skill add --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name/);
});
test("skill update --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name/);
});
test("skill remove --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "remove", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name/);
});
test("skill list --help exits successfully", async () => {
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "list", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/list|registry/i);
});
});
// Local-only cases: auth "none" + validation happens before any network access, no gating needed
describe("e2e: skill (local, no credentials)", () => {
test("skill add without --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
});
test("skill remove without --name errors as usage error (2)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"remove",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
});
test("skill add rejects mixing all with specific names (2)", async () => {
// parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
"skill",
"add",
"--name",
"all,spark-video",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(`${stdout}\n${stderr}`).toMatch(/all/i);
});
test("skill remove of a not-installed skill fails with reason (1)", async () => {
const configDir = makeTempConfigDir();
const { stdout, exitCode } = await runCommandE2e(
SKILL_ROUTES,
["skill", "remove", "--name", "definitely-not-installed", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode).toBe(1);
const data = parseStdoutJson<{
skills?: Array<{ name?: string; status?: string; reason?: string }>;
}>(stdout);
expect(data.skills?.[0]?.status).toBe("failed");
expect(data.skills?.[0]?.reason).toMatch(/not installed/i);
});
});
describe.skipIf(!isBailianE2EEnabled())("e2e: skill (real registry)", () => {
test("skill list --output json returns registry and status rows", async () => {
const configDir = makeTempConfigDir();
const { stdout, stderr, exitCode } = await runCommandE2e(
SKILL_ROUTES,
["skill", "list", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
registry?: string;
skills?: Array<{ name?: string; status?: string }>;
}>(stdout);
expect(data.registry).toMatch(/^https?:\/\//);
expect(Array.isArray(data.skills)).toBe(true);
}, 60_000);
test("skill add + remove full lifecycle in isolated dirs", async () => {
const configDir = makeTempConfigDir();
// Empty fake home → no agents detected → fan-out never leaves the sandbox
const fakeHome = makeTempConfigDir();
const env = { BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome };
const added = await runCommandE2e(
SKILL_ROUTES,
["skill", "add", "--name", WIKI_SKILL, "--output", "json"],
env,
);
expect(added.exitCode, added.stderr).toBe(0);
const addData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>(
added.stdout,
);
expect(addData.skills?.[0]?.status).toBe("installed");
expect(existsSync(join(configDir, "skills", WIKI_SKILL, "SKILL.md"))).toBe(true);
const removed = await runCommandE2e(
SKILL_ROUTES,
["skill", "remove", "--name", WIKI_SKILL, "--output", "json"],
env,
);
expect(removed.exitCode, removed.stderr).toBe(0);
const removeData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>(
removed.stdout,
);
expect(removeData.skills?.[0]?.status).toBe("removed");
expect(existsSync(join(configDir, "skills", WIKI_SKILL))).toBe(false);
}, 300_000);
});
@@ -20,7 +20,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
"text",
"chat",
"--model",
"qwen3.7-max",
"qwen3.8-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--message|Usage:/i);
@@ -32,7 +32,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
"chat",
"--dry-run",
"--model",
"qwen3.7-max",
"qwen3.8-max",
"--message",
"干跑",
"--max-tokens",
@@ -44,16 +44,16 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chatDashScope", () => {
const data = parseStdoutJson<{
request?: { model?: string; messages?: Array<{ content?: string }> };
}>(stdout);
expect(data.request?.model).toBe("qwen3.7-max");
expect(data.request?.model).toBe("qwen3.8-max");
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
});
test("【qwen3.7-max】文本对话", async () => {
test("【qwen3.8-max】文本对话", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
"text",
"chat",
"--model",
"qwen3.7-max",
"qwen3.8-max",
"--message",
"只回复一个字:好",
"--max-tokens",
@@ -10,6 +10,10 @@ export const AUTH_ROUTES: E2eRouteExports = {
"auth logout": "authLogout",
};
export const UPDATE_ROUTES: E2eRouteExports = {
update: "update",
};
export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
export const CONFIG_ROUTES: E2eRouteExports = {
@@ -156,6 +160,13 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
"token-plan add-member": "tokenPlanAddMember",
};
export const SKILL_ROUTES: E2eRouteExports = {
"skill add": "skillAdd",
"skill update": "skillUpdate",
"skill remove": "skillRemove",
"skill list": "skillList",
};
export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
"managed-agent init": "managedAgentInit",
"managed-agent validate": "managedAgentValidate",

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