Compare commits

..

66 Commits

Author SHA1 Message Date
chenanran555 ac48c3ec23 faet(agent): add sync and migrate commands 2026-08-10 20:55:02 +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
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
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
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
故璃 d08edf0cd8 feat: sync wiki data from oss by fc 2026-07-17 16:43:06 +08:00
174 changed files with 9495 additions and 771 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 }}"
+5
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
# Dependencies & build output
node_modules
dist
dist-bin
dist-ssr
tools/generated
.node-version
@@ -37,6 +38,7 @@ tools/generated
.claude/scheduled_tasks.lock
.cursor/
.qwen/
.qoder
.playwright-mcp/
.pnpm-store/
@@ -46,3 +48,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`,把清单沉淀下来。
+17
View File
@@ -6,6 +6,23 @@ 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.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
+17
View File
@@ -6,6 +6,23 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [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
### 新增
+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 |
+13 -3
View File
@@ -24,7 +24,7 @@ _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
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
@@ -77,11 +77,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
@@ -210,8 +219,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`
+14 -2
View File
@@ -24,7 +24,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **文本对话** — Qwen3.8-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
@@ -75,11 +75,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` 长期保留
## 快速开始
@@ -210,6 +219,9 @@ bl config set --key timeout --value 600
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
+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` 等正式对外发布时再补。
验证:
+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:"
+13 -3
View File
@@ -24,7 +24,7 @@ _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
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
@@ -77,11 +77,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
@@ -210,8 +219,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`
+14 -2
View File
@@ -24,7 +24,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
- **文本对话** — Qwen3.7-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **文本对话** — Qwen3.8-maxAgentic coding、前端编程、Vibe coding 等能力显著增强
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
@@ -75,11 +75,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` 长期保留
## 快速开始
@@ -210,6 +219,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.13.0",
"version": "1.14.0",
"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);
});
+12
View File
@@ -89,8 +89,14 @@ import {
pluginLink,
pluginList,
pluginRemove,
skillAdd,
skillUpdate,
skillRemove,
skillList,
managedAgentInit,
managedAgentValidate,
managedAgentSync,
managedAgentMigrate,
managedAgentPlan,
managedAgentApply,
managedAgentDestroy,
@@ -203,8 +209,14 @@ export const commands: Record<string, AnyCommand> = {
"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 sync": managedAgentSync,
"managed-agent migrate": managedAgentMigrate,
"managed-agent plan": managedAgentPlan,
"managed-agent apply": managedAgentApply,
"managed-agent destroy": managedAgentDestroy,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.13.0",
"version": "1.14.0",
"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";
@@ -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",
@@ -60,7 +60,7 @@ export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
// 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.7-max",
default_text_model: "qwen3.8-max",
default_image_model: "qwen-image-2.0",
default_video_model: "happyhorse-1.1-t2v",
default_speech_model: "cosyvoice-v3-flash",
@@ -80,7 +80,7 @@ export interface ModelOption {
// 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.7-max", role: "text/chat default" },
{ 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" },
@@ -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);
}
@@ -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(
@@ -0,0 +1,221 @@
import {
listCloudEnvironments,
listCloudVaults,
listFiles,
listSkills,
type ProjectRuntimeContext,
type SyncProjectResult,
} from "@openagentpack/sdk";
import { BailianError, ExitCode } from "bailian-cli-core";
/** Resource types the SDK can reverse-export (its syncable whitelist). */
export const SYNCABLE_TYPES = ["environment", "vault", "file", "skill", "agent"] as const;
export type SyncableType = (typeof SYNCABLE_TYPES)[number];
/**
* Types selectable by remote ID through the shared resolution below. Agents are
* excluded: their synced yaml keys ARE remote IDs, so the sync command narrows
* them with a direct key hit (plus referenced-skill handling) instead.
*/
export type SelectableResourceType = Exclude<SyncableType, "agent">;
/** Maps a syncable type to its top-level group key in the synced config. */
const GROUP_KEY: Record<SyncableType, string> = {
environment: "environments",
vault: "vaults",
file: "files",
skill: "skills",
agent: "agents",
};
/** Remote id lookup outcome: human label + optional `agents.resource` yaml key tag. */
interface LocatedRemote {
label?: string;
taggedKey?: string;
}
/**
* Narrow one resource group of a synced config to the single entry identified
* by its remote ID; returns the kept yaml key.
*
* Group keys are only guaranteed to be remote IDs for agents; the other types
* derive their key from the `agents.resource` metadata tag or a display-name
* slug. Resolution therefore tries, in order: direct key hit remote lookup
* by ID (provider list API) metadata-tag key label match against the
* exported declarations. Misses and ambiguity fail loudly instead of guessing.
*/
export async function narrowGroupToRemoteId(
runtime: ProjectRuntimeContext,
provider: string,
result: SyncProjectResult,
type: SelectableResourceType,
remoteId: string,
): Promise<string> {
const groupKey = GROUP_KEY[type];
const group = (result.config[groupKey] ?? {}) as Record<string, Record<string, unknown>>;
let keptKey: string;
if (remoteId in group) {
keptKey = remoteId;
} else {
const located = await locateRemote(runtime, provider, type, remoteId);
const resolved =
located.taggedKey && located.taggedKey in group
? located.taggedKey
: matchByLabel(type, group, located.label);
if (!resolved) {
throw new BailianError(
`Remote ${type} '${remoteId}'${located.label ? ` (${located.label})` : ""} has no matching entry in the synced output.`,
ExitCode.GENERAL,
"The resource may be archived or renamed; run a full sync (without the id flag) to inspect the exported keys.",
);
}
keptKey = resolved;
}
result.config[groupKey] = { [keptKey]: group[keptKey]! };
if (type in result.counts) result.counts[type] = 1;
if (type === "skill" && result.skillFiles) {
for (const skillName of result.skillFiles.keys()) {
if (skillName !== keptKey) result.skillFiles.delete(skillName);
}
}
return keptKey;
}
/** Look a remote resource up by ID via the provider's list API. */
async function locateRemote(
runtime: ProjectRuntimeContext,
provider: string,
type: SelectableResourceType,
remoteId: string,
): Promise<LocatedRemote> {
if (type === "environment") {
const environments = await listCloudEnvironments(runtime, { provider });
const hit = environments.find((environment) => environment.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
environments.map((environment) => formatCandidate(environment.id, environment.name)),
);
}
return { label: hit.name, taggedKey: hit.metadata?.["agents.resource"] };
}
if (type === "vault") {
const vaults = await listCloudVaults(runtime, { provider });
const hit = vaults.find((vault) => vault.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
vaults.map((vault) => formatCandidate(vault.id, vault.display_name)),
);
}
return { label: hit.display_name, taggedKey: hit.metadata?.["agents.resource"] };
}
if (type === "file") {
const files = await listFiles(runtime, { provider });
const hit = files.find((fileInfo) => fileInfo.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
files.map((fileInfo) => formatCandidate(fileInfo.id, fileInfo.filename)),
);
}
return { label: hit.filename };
}
// Synced skills come from the workspace's custom catalog (the raw /skills listing).
const skills = await listSkills(runtime, { provider, source: "custom" });
const hit = skills.find((skill) => skill.id === remoteId);
if (!hit) {
throw notFound(
type,
remoteId,
skills.map((skill) => formatCandidate(skill.id, skill.name)),
);
}
return { label: hit.name };
}
/**
* Batch-resolve remote skill IDs to synced skills-group keys via the custom
* skill catalog: look each ID up for its display name, then match group keys
* on normalized label (skills keys are display-name slugs). IDs that cannot be
* resolved or whose label matches more than one key come back as
* unmatched for the caller to surface. One catalog call serves the whole batch.
*/
export async function resolveSkillKeysByIds(
runtime: ProjectRuntimeContext,
provider: string,
group: Record<string, Record<string, unknown>>,
remoteIds: string[],
): Promise<{ resolved: Map<string, string>; unmatched: string[] }> {
const resolved = new Map<string, string>();
const unmatched: string[] = [];
if (remoteIds.length === 0) return { resolved, unmatched };
const catalog = await listSkills(runtime, { provider, source: "custom" });
for (const remoteId of remoteIds) {
const hit = catalog.find((skill) => skill.id === remoteId);
const matches = hit
? Object.keys(group).filter((key) => normalizeLabel(key) === normalizeLabel(hit.name))
: [];
if (matches.length === 1) {
resolved.set(remoteId, matches[0]!);
} else {
unmatched.push(remoteId);
}
}
return { resolved, unmatched };
}
/**
* Match a group entry by the remote resource's human label. Vaults and files
* carry the label verbatim in their exported decl; environment and skill keys
* are display-name slugs, so those compare on normalized alphanumerics.
*/
function matchByLabel(
type: SelectableResourceType,
group: Record<string, Record<string, unknown>>,
label: string | undefined,
): string | undefined {
if (!label) return undefined;
const matches = Object.entries(group).filter(([key, decl]) => {
if (type === "vault") return decl.display_name === label;
if (type === "file") return decl.name === label || decl.source === label;
return normalizeLabel(key) === normalizeLabel(label);
});
if (matches.length > 1) {
throw new BailianError(
`Multiple synced ${type} entries match '${label}': ${matches.map(([key]) => key).join(", ")}.`,
ExitCode.GENERAL,
"Run a full sync (without the id flag) and narrow the output manually.",
);
}
return matches[0]?.[0];
}
/** Compare display labels and slug-derived keys on lowercase alphanumerics only. */
function normalizeLabel(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function notFound(type: string, remoteId: string, candidates: string[]): BailianError {
const shown = candidates.slice(0, 20);
const suffix =
candidates.length > shown.length ? `, … ${candidates.length - shown.length} more` : "";
return new BailianError(
`${type} '${remoteId}' not found on the remote workspace.`,
ExitCode.USAGE,
candidates.length > 0
? `Available: ${shown.join(", ")}${suffix}.`
: `The workspace has no syncable ${type} resources.`,
);
}
function formatCandidate(id: string, label?: string): string {
return label && label !== id ? `${id} (${label})` : id;
}
@@ -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 =
@@ -0,0 +1,123 @@
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import {
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
import { migrateConfig } from "@openagentpack/sdk";
import { parse as parseYaml } from "yaml";
import { withAgentErrors } from "./_engine/errors.ts";
/** bl's migrate is bailian-only: the merge target must resolve to bailian. */
const MIGRATE_PROVIDER = "bailian";
const MIGRATE_FLAGS = {
from: {
type: "string",
valueHint: "<path>",
description: "Synced config to migrate from (default: agents.synced.yaml)",
},
to: {
type: "string",
valueHint: "<path>",
description: "Target agents.yaml to merge into (default: agents.yaml)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Merge a synced config into a bailian agents.yaml",
auth: "apiKey",
usageArgs: "[--from <path>] [--to <path>]",
flags: MIGRATE_FLAGS,
exampleArgs: ["", "--from agents.synced.yaml --to agents.yaml"],
notes: [
"The merge itself runs against local files; bl's unified apiKey gate still applies — login via `bl auth login`, pass --api-key, or set DASHSCOPE_API_KEY.",
"Only a bailian-target agents.yaml is supported: migrated resources are re-pointed to provider bailian, with models/tools/environments normalized to Bailian-supported values.",
"Resources whose YAML key already exists in the target are skipped, never overwritten.",
"Run `bl managed-agent plan` afterwards to review the merged config before apply.",
],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const fromPath = flags.from ?? "agents.synced.yaml";
const toPath = flags.to ?? "agents.yaml";
// Aligned with the other managed-agent commands: dry-run short-circuits
// first and only echoes the planned action — no file I/O, no validation.
if (settings.dryRun) {
emitResult({ would_migrate: { from: fromPath, to: toPath } }, format);
return;
}
const result = await withAgentErrors(async () => {
await assertBailianTarget(toPath);
return migrateConfig({ fromPath, toPath });
});
await writeFile(toPath, result.yaml, "utf8");
if (format === "json") {
emitResult(
{ migrated: toPath, from: fromPath, added: result.added, skipped: result.skipped },
format,
);
return;
}
const addedParts = Object.entries(result.added).map(([group, count]) => `${count} ${group}`);
const skippedParts = Object.entries(result.skipped).map(
([group, count]) => `${count} ${group}`,
);
if (addedParts.length > 0) {
emitBare(`Migrated ${addedParts.join(", ")} into ${toPath}.`);
} else {
emitBare("No new resources to migrate (all already exist in target).");
}
if (skippedParts.length > 0) {
emitBare(`Skipped (already exist): ${skippedParts.join(", ")}.`);
}
if (addedParts.length > 0) {
// `bl` prefix is safe: agent commands ship on `bl` only.
emitBare("Next: run `bl managed-agent plan` to review the merged config.");
}
},
});
/**
* Enforce the bailian-only contract before merging: the target file must exist
* and its provider (defaults.provider, else the first providers key) must be
* bailian. An undeterminable provider is left to the SDK's own error.
*/
async function assertBailianTarget(toPath: string): Promise<void> {
if (!existsSync(toPath)) {
throw new BailianError(
`Target file '${toPath}' not found.`,
ExitCode.USAGE,
// `bl` prefix is safe: agent commands ship on `bl` only.
"Create it first with `bl managed-agent init`, then re-run migrate.",
);
}
const parsed: unknown = parseYaml(await readFile(toPath, "utf8"));
if (!parsed || typeof parsed !== "object") return;
const config = parsed as Record<string, unknown>;
const defaults = config.defaults as Record<string, unknown> | undefined;
const providers = config.providers as Record<string, unknown> | undefined;
const targetProvider =
typeof defaults?.provider === "string" && defaults.provider
? defaults.provider
: Object.keys(providers ?? {})[0];
if (targetProvider && targetProvider !== MIGRATE_PROVIDER) {
throw new BailianError(
`Target provider '${targetProvider}' is not supported: migrate only targets the ${MIGRATE_PROVIDER} provider.`,
ExitCode.USAGE,
`Set defaults.provider to ${MIGRATE_PROVIDER} (or make ${MIGRATE_PROVIDER} the providers block) in '${toPath}'.`,
);
}
}
@@ -0,0 +1,465 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
import {
type ProjectRuntimeContext,
type SyncProjectResult,
syncProviderResourcesFromContext,
} from "@openagentpack/sdk";
import { stringify as stringifyYaml } from "yaml";
import {
assertProviderConfigured,
buildAgentRuntime,
CREDENTIALS_NOTE,
} from "./_engine/config-loader.ts";
import { withStdoutProtected } from "./_engine/console-capture.ts";
import { withAgentErrors } from "./_engine/errors.ts";
import {
narrowGroupToRemoteId,
resolveSkillKeysByIds,
type SelectableResourceType,
SYNCABLE_TYPES,
type SyncableType,
} from "./_engine/sync-selection.ts";
/** bl's sync is bailian-only: the reverse-export source is always AgentStudio. */
const SYNC_PROVIDER = "bailian";
const DEFAULT_SYNC_OUTPUT = "agents.synced.yaml";
const SYNC_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: "Config file path (default: agents.yaml)",
},
out: {
type: "string",
valueHint: "<path>",
description: `Output path for the synced config (default: ${DEFAULT_SYNC_OUTPUT})`,
},
types: {
type: "string",
valueHint: "<list>",
description: `Comma-separated resource types to sync: ${SYNCABLE_TYPES.join(", ")} (default: all)`,
},
agentId: {
type: "string",
valueHint: "<id>",
description: "Sync a single agent by its remote ID (plus the skills it references)",
},
environmentId: {
type: "string",
valueHint: "<id>",
description: "Sync a single environment by its remote ID",
},
vaultId: {
type: "string",
valueHint: "<id>",
description: "Sync a single vault by its remote ID",
},
fileId: {
type: "string",
valueHint: "<id>",
description: "Sync a single file resource by its remote ID",
},
skillId: {
type: "string",
valueHint: "<id>",
description: "Sync a single skill by its remote ID (overrides --agent-id skill narrowing)",
},
force: {
type: "switch",
description: "Overwrite an existing output file",
},
skipMissingFiles: {
type: "switch",
description: "Drop file resources whose local source is missing instead of keeping them",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Export remote bailian resources into a local synced config",
auth: "apiKey",
usageArgs:
"[--types <list>] [--agent-id|--environment-id|--vault-id|--file-id|--skill-id <id>] [--file <path>] [--out <path>] [--force] [--skip-missing-files]",
flags: SYNC_FLAGS,
exampleArgs: [
"",
"--types agent,skill",
"--agent-id agent-abc123",
"--skill-id skill-xyz --types skill",
"--force --skip-missing-files",
],
notes: [
...CREDENTIALS_NOTE,
"Syncs from the bailian provider only: remote AgentStudio resources (environments, vaults, files, skills, agents) are exported into a local synced config for review.",
"--types narrows the export to the listed resource types (plural spellings accepted); sessions are runtime instances, not syncable resources.",
"With --agent-id, the agents group keeps only that agent (yaml key = remote agent ID) and its referenced custom skills are synced along — the skill group is exported even when --types omits it; official skill references need no local entry.",
"Bailian binds environments/vaults/files at the session level, not on the agent, so --agent-id keeps those groups as shared infrastructure.",
"Each --environment-id/--vault-id/--file-id/--skill-id narrows its own resource group to the single remote resource (yaml key resolved from the remote listing); combine with --types for a minimal output.",
"Requires an agents.yaml with a bailian provider block — run `bl managed-agent init` first.",
"Secrets are never exported: vault credentials keep ${ENV} placeholders; set those env vars locally before apply.",
"Merge the synced config into agents.yaml with `bl managed-agent migrate`.",
],
validate: (flagValues) => {
if (!flagValues.types) return undefined;
const { types, invalid } = parseSyncTypes(flagValues.types);
if (invalid.length > 0) {
return `--types contains unsupported values: ${invalid.join(", ")}. Valid types: ${SYNCABLE_TYPES.join(", ")}.`;
}
if (types.length === 0) {
return `--types must list at least one of: ${SYNCABLE_TYPES.join(", ")}.`;
}
const idFlagByType: Record<SyncableType, string | undefined> = {
agent: flagValues.agentId,
environment: flagValues.environmentId,
vault: flagValues.vaultId,
file: flagValues.fileId,
skill: flagValues.skillId,
};
for (const [selectionType, value] of Object.entries(idFlagByType)) {
if (value && !types.includes(selectionType as SyncableType)) {
return `--${selectionType}-id requires --types to include ${selectionType}.`;
}
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const file = flags.file ?? "agents.yaml";
const out = flags.out ?? DEFAULT_SYNC_OUTPUT;
// undefined → the SDK exports every syncable type.
let types = flags.types ? parseSyncTypes(flags.types).types : undefined;
// --agent-id pulls the agent's referenced skills along, so the skill group
// is exported even when --types omits it.
if (types && flags.agentId && !types.includes("skill")) types = [...types, "skill"];
// Per-group single-resource selections, resolved by remote ID after export.
const resourceSelections: Partial<Record<SelectableResourceType, string>> = {
environment: flags.environmentId,
vault: flags.vaultId,
file: flags.fileId,
skill: flags.skillId,
};
// Aligned with the other managed-agent commands: dry-run short-circuits
// first and only echoes the planned action — no guards, no file I/O.
if (settings.dryRun) {
emitResult(
{
would_sync: {
provider: SYNC_PROVIDER,
config_file: file,
out,
types,
agent_id: flags.agentId,
environment_id: flags.environmentId,
vault_id: flags.vaultId,
file_id: flags.fileId,
skill_id: flags.skillId,
},
},
format,
);
return;
}
if (existsSync(out) && !flags.force) {
throw new BailianError(
`${out} already exists.`,
ExitCode.USAGE,
"Pass --force to overwrite, or --out to write elsewhere.",
);
}
const { result, agentFilter, selectedKeys } = await withAgentErrors(() =>
withStdoutProtected(async () => {
const runtime = await buildAgentRuntime(ctx, file);
assertProviderConfigured(runtime, SYNC_PROVIDER);
const synced = await syncProviderResourcesFromContext(runtime, {
provider: SYNC_PROVIDER,
types,
});
// An explicit --skill-id wins over the agent's referenced-skill narrowing.
const filtered = flags.agentId
? await filterConfigToAgent(runtime, synced, flags.agentId, {
narrowSkills: !flags.skillId,
})
: undefined;
const keptKeys: Partial<Record<SelectableResourceType, string>> = {};
for (const [selectionType, remoteId] of Object.entries(resourceSelections)) {
if (!remoteId) continue;
keptKeys[selectionType as SelectableResourceType] = await narrowGroupToRemoteId(
runtime,
SYNC_PROVIDER,
synced,
selectionType as SelectableResourceType,
remoteId,
);
}
return { result: synced, agentFilter: filtered, selectedKeys: keptKeys };
}),
);
const narrowed = Boolean(agentFilter) || Object.keys(selectedKeys).length > 0;
const baseDir = dirname(out);
const removedFiles = flags.skipMissingFiles
? removeMissingFileSources(result.config, baseDir)
: [];
const yamlContent =
narrowed || removedFiles.length > 0
? stringifyYaml(result.config, { lineWidth: 0 })
: result.yaml;
await writeFile(out, yamlContent, "utf8");
const downloadedSkillFiles = await writeDownloadedSkillFiles(result, baseDir);
// Custom skills whose content could not be downloaded need local files
// before an apply would round-trip; surface them instead of prompting.
const missingSkillSources = collectMissingSkillSources(result.config, baseDir);
const secretEnvVars = (result.secretPlaceholders ?? []).map(
(placeholder) => placeholder.envVar,
);
if (format === "json") {
emitResult(
{
synced: out,
provider: SYNC_PROVIDER,
agent_id: flags.agentId,
selected_keys: Object.keys(selectedKeys).length > 0 ? selectedKeys : undefined,
counts: result.counts,
skill_files_downloaded: downloadedSkillFiles,
removed_files: removedFiles,
missing_skill_sources: missingSkillSources,
secret_env_vars: secretEnvVars,
unmatched_skill_refs: agentFilter?.unmatchedSkillIds,
},
format,
);
return;
}
const countParts = Object.entries(result.counts).map(([type, count]) => `${count} ${type}(s)`);
emitBare(
`Synced ${countParts.length > 0 ? countParts.join(", ") : "0 resources"} from ${SYNC_PROVIDER} into ${out}.`,
);
if (agentFilter) {
emitBare(
agentFilter.keptSkills
? `Narrowed to agent ${flags.agentId} (kept ${agentFilter.keptSkills.length} referenced skill(s)).`
: `Narrowed to agent ${flags.agentId}.`,
);
if (agentFilter.unmatchedSkillIds.length > 0) {
emitBare(
`Skill references without a matching skills entry (kept on the agent as-is): ${agentFilter.unmatchedSkillIds.join(", ")}.`,
);
}
}
for (const [selectionType, keptKey] of Object.entries(selectedKeys)) {
const requestedId = resourceSelections[selectionType as SelectableResourceType];
emitBare(
`Narrowed ${selectionType} to ${requestedId}${keptKey !== requestedId ? ` (key: ${keptKey})` : ""}.`,
);
}
if (downloadedSkillFiles > 0) {
emitBare(`Downloaded ${downloadedSkillFiles} skill file(s) into ./skills/.`);
}
if (removedFiles.length > 0) {
emitBare(
`Removed ${removedFiles.length} file resource(s) with missing local sources: ${removedFiles.join(", ")}.`,
);
}
if (missingSkillSources.length > 0) {
emitBare(
`Missing local skill sources (provide the files before apply): ${missingSkillSources.join(", ")}.`,
);
}
if (secretEnvVars.length > 0) {
emitBare(`Set these env vars locally before apply: ${secretEnvVars.join(", ")}.`);
}
// `bl` prefix is safe: agent commands ship on `bl` only.
emitBare(
`Next: review ${out}, then run \`bl managed-agent migrate\` to merge it into agents.yaml.`,
);
},
});
/**
* Parse the --types list. Accepts singular and plural spellings ("skills"
* "skill") and dedupes; invalid tokens are returned verbatim so validate()
* can reject them with the original user input.
*/
function parseSyncTypes(raw: string): { types: SyncableType[]; invalid: string[] } {
const types: SyncableType[] = [];
const invalid: string[] = [];
const tokens = raw
.split(",")
.map((token) => token.trim())
.filter(Boolean);
for (const token of tokens) {
const singular = token.endsWith("s") ? token.slice(0, -1) : token;
const matched = SYNCABLE_TYPES.find(
(candidate) => candidate === token || candidate === singular,
);
if (!matched) {
invalid.push(token);
} else if (!types.includes(matched)) {
types.push(matched);
}
}
return { types, invalid };
}
/** Result of narrowing a synced config to a single agent. */
interface AgentFilterResult {
/** Undefined when skills narrowing was skipped (--skill-id takes over). */
keptSkills?: string[];
unmatchedSkillIds: string[];
}
/**
* Narrow a full synced config to a single agent: the agents group keeps only
* the entry whose yaml key equals the remote agent ID, and unless an
* explicit --skill-id selection takes over (`narrowSkills: false`) the
* skills group keeps only the custom skills that agent references (official
* skills live in the provider catalog and need no local entry). Skill
* references carry remote skill IDs while skills-group keys are
* metadata/display-name derived, so key misses are resolved through the
* custom skill catalog before being surfaced as unmatched (never silently
* dropped from the agent itself). Downloaded skill files and counts are
* re-scoped to what remains.
*/
async function filterConfigToAgent(
runtime: ProjectRuntimeContext,
result: SyncProjectResult,
agentId: string,
options: { narrowSkills: boolean },
): Promise<AgentFilterResult> {
const config = result.config;
const agents = (config.agents ?? {}) as Record<string, Record<string, unknown>>;
const selected = agents[agentId];
if (!selected) {
const available = Object.keys(agents);
throw new BailianError(
`Agent '${agentId}' not found on the remote workspace.`,
ExitCode.USAGE,
available.length > 0
? `Available agent IDs: ${available.join(", ")}.`
: "The workspace has no syncable (non-archived) agents.",
);
}
config.agents = { [agentId]: selected };
if ("agent" in result.counts) result.counts.agent = 1;
if (!options.narrowSkills) {
return { unmatchedSkillIds: [] };
}
const skillRefs = Array.isArray(selected.skills)
? (selected.skills as Array<Record<string, unknown>>)
: [];
// Official skills resolve against the provider catalog at apply time; only
// custom references map to synced skills-group declarations.
const referencedIds = new Set(
skillRefs
.filter((skillRef) => skillRef.type !== "official")
.map((skillRef) => skillRef.skill_id)
.filter((skillId): skillId is string => typeof skillId === "string"),
);
const skills = (config.skills ?? {}) as Record<string, Record<string, unknown>>;
const keptSkills = Object.keys(skills).filter((key) => referencedIds.has(key));
const pendingIds = Array.from(referencedIds).filter((skillId) => !(skillId in skills));
// Key misses: the ref carries a remote ID while the group key is a
// display-name slug — resolve through the custom catalog before declaring
// the reference unmatched.
const { resolved, unmatched: unmatchedSkillIds } = await resolveSkillKeysByIds(
runtime,
SYNC_PROVIDER,
skills,
pendingIds,
);
for (const resolvedKey of resolved.values()) {
if (!keptSkills.includes(resolvedKey)) keptSkills.push(resolvedKey);
}
if (keptSkills.length > 0) {
config.skills = Object.fromEntries(keptSkills.map((key) => [key, skills[key]!]));
} else {
delete config.skills;
}
if (result.skillFiles) {
const kept = new Set(keptSkills);
for (const skillName of result.skillFiles.keys()) {
if (!kept.has(skillName)) result.skillFiles.delete(skillName);
}
}
if ("skill" in result.counts) result.counts.skill = keptSkills.length;
return { keptSkills, unmatchedSkillIds };
}
/**
* Drop file resources whose `source` does not exist locally the remote
* platform cannot hand file content back, so keeping them would make the
* synced config un-appliable. Returns the removed YAML keys.
*/
function removeMissingFileSources(config: Record<string, unknown>, baseDir: string): string[] {
const files = (config.files ?? {}) as Record<string, Record<string, unknown>>;
const removed = Object.entries(files)
.filter(
([, decl]) => typeof decl.source === "string" && !existsSync(join(baseDir, decl.source)),
)
.map(([key]) => key);
for (const key of removed) {
delete files[key];
}
if (Object.keys(files).length === 0) {
delete config.files;
}
return removed;
}
/** Persist provider-downloaded skill files under ./skills/<name>/; returns the file count. */
async function writeDownloadedSkillFiles(
result: SyncProjectResult,
baseDir: string,
): Promise<number> {
if (!result.skillFiles?.size) return 0;
let written = 0;
for (const [skillName, skillFileList] of result.skillFiles) {
for (const skillFile of skillFileList) {
const filePath = join(baseDir, "skills", skillName, skillFile.relativePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, skillFile.content);
written++;
}
}
return written;
}
/** Custom skills whose local source dir/file is absent or empty after sync. */
function collectMissingSkillSources(config: Record<string, unknown>, baseDir: string): string[] {
const skills = (config.skills ?? {}) as Record<string, Record<string, unknown>>;
const missing: string[] = [];
for (const [key, decl] of Object.entries(skills)) {
if (decl.origin !== "custom" || typeof decl.source !== "string") continue;
const sourcePath = join(baseDir, decl.source);
if (!existsSync(sourcePath)) {
missing.push(key);
continue;
}
const sourceStat = statSync(sourcePath);
if (sourceStat.isDirectory() && readdirSync(sourcePath).length === 0) {
missing.push(key);
}
}
return missing;
}
+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`);
}
+6
View File
@@ -93,6 +93,8 @@ export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-se
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
export { default as managedAgentSync } from "./commands/managed-agent/sync.ts";
export { default as managedAgentMigrate } from "./commands/managed-agent/migrate.ts";
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
@@ -113,3 +115,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";
+1 -1
View File
@@ -90,7 +90,7 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
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-2.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.7-max");
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");
+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",
@@ -0,0 +1,15 @@
version: "1"
providers:
claude:
api_key: ${ANTHROPIC_API_KEY}
defaults:
provider: claude
agents:
assistant:
description: "E2E claude-target fixture"
model: claude-sonnet-4-6
instructions: |
You are a helpful assistant.
@@ -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.
@@ -0,0 +1,30 @@
version: "1"
providers:
bailian:
api_key: ${DASHSCOPE_API_KEY}
base_url: ${BAILIAN_BASE_URL}
environments:
dev:
config:
type: cloud
networking:
type: unrestricted
provider: bailian
agents:
assistant:
description: "E2E synced fixture (already exists in target)"
model: qwen3.7-max
instructions: |
You are a helpful assistant.
environment: dev
provider: bailian
reviewer:
description: "E2E synced fixture (new agent)"
model: qwen3.7-max
instructions: |
You review code.
environment: dev
provider: bailian
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { e2eFixturesDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
/**
@@ -245,3 +246,191 @@ describe("e2e: managed-agent--dry-run 短路,不联网不写盘)", () =>
expect(data.remote_id).toBe("agent-e2e");
});
});
describe("e2e: managed-agent sync / migratebailian-only", () => {
const fixturesDir = join(e2eFixturesDir, "managed-agent");
const agentsYaml = join(fixturesDir, "agents.yaml");
const agentsSyncedYaml = join(fixturesDir, "agents-synced.yaml");
const agentsClaudeYaml = join(fixturesDir, "agents-claude.yaml");
test("managed-agent sync --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--out|--force|--skip-missing-files/i);
expect(stderr).toMatch(/--agent-id/i);
expect(stderr).toMatch(/--types/i);
expect(stderr).toMatch(/--environment-id/i);
expect(stderr).toMatch(/--vault-id/i);
expect(stderr).toMatch(/--file-id/i);
expect(stderr).toMatch(/--skill-id/i);
});
test("sync 非法 --types 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--types",
"agent,sessions",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--types contains unsupported values: sessions/i);
});
test("sync --agent-id 搭配不含 agent 的 --types 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--agent-id",
"agent-e2e",
"--types",
"skill",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--agent-id requires --types to include agent/i);
});
test("sync --vault-id 搭配不含 vault 的 --types 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--vault-id",
"vault-e2e",
"--types",
"agent",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--vault-id requires --types to include vault/i);
});
test("sync 输出文件已存在且未 --force 时退出为用法错误 (2)", async () => {
// auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证,
// 命令仍会在覆盖写守卫处短路(先于构建 SDK runtime不产生任何网络请求。
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
["managed-agent", "sync", "--out", agentsYaml, "--quiet"],
{ DASHSCOPE_API_KEY: "sk-e2e-sync" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/already exists/i);
});
test("sync --dry-run 仅回显计划,即使 --out 已存在也不报错", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--dry-run",
"--agent-id",
"agent-e2e",
"--skill-id",
"skill-e2e",
"--types",
"agents,skills",
"--out",
agentsYaml,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_sync?: {
provider?: string;
out?: string;
agent_id?: string;
skill_id?: string;
types?: string[];
};
}>(stdout);
expect(data.would_sync?.provider).toBe("bailian");
expect(data.would_sync?.out).toBe(agentsYaml);
expect(data.would_sync?.agent_id).toBe("agent-e2e");
expect(data.would_sync?.skill_id).toBe("skill-e2e");
// 复数拼写归一化为 SDK 的单数资源类型
expect(data.would_sync?.types).toEqual(["agent", "skill"]);
});
test("sync --agent-id 时 --types 自动补充 skill关联技能联动导出", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"sync",
"--dry-run",
"--agent-id",
"agent-e2e",
"--types",
"agent",
"--out",
"agents.synced.e2e-missing.yaml",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_sync?: { types?: string[] } }>(stdout);
expect(data.would_sync?.types).toEqual(["agent", "skill"]);
});
test("managed-agent migrate --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"migrate",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--from|--to/i);
});
test("migrate 目标文件缺失时退出为用法错误 (2)", async () => {
// auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证,
// 命令仍会在目标文件守卫处短路,不产生任何网络请求。
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
[
"managed-agent",
"migrate",
"--from",
agentsSyncedYaml,
"--to",
"agents.e2e-missing.yaml",
"--quiet",
],
{ DASHSCOPE_API_KEY: "sk-e2e-migrate" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Target file .*agents\.e2e-missing\.yaml.*not found/i);
});
test("migrate 目标 provider 非 bailian 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(
MANAGED_AGENT_ROUTES,
["managed-agent", "migrate", "--from", agentsSyncedYaml, "--to", agentsClaudeYaml, "--quiet"],
{ DASHSCOPE_API_KEY: "sk-e2e-migrate" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/only targets the bailian provider/i);
});
test("migrate --dry-run 仅回显计划,目标缺失也不报错", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"migrate",
"--dry-run",
"--from",
agentsSyncedYaml,
"--to",
"agents.e2e-missing.yaml",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_migrate?: { from?: string; to?: string };
}>(stdout);
expect(data.would_migrate?.from).toBe(agentsSyncedYaml);
expect(data.would_migrate?.to).toBe("agents.e2e-missing.yaml");
});
});
@@ -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,9 +160,18 @@ 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",
"managed-agent sync": "managedAgentSync",
"managed-agent migrate": "managedAgentMigrate",
"managed-agent plan": "managedAgentPlan",
"managed-agent apply": "managedAgentApply",
"managed-agent destroy": "managedAgentDestroy",
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vite-plus/test";
import { runCommandE2e } from "./helpers.ts";
import { UPDATE_ROUTES } from "./topic-routes.ts";
describe("e2e: update", () => {
test("update --help 正常退出并展示 --to", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--to/);
expect(stderr).toMatch(/<version>/);
});
test("update --help 包含 --to 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--to 0.1.14");
});
test("update --to 缺值时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--to"]);
expect(exitCode, stderr).toBe(2);
});
test("update --to 非法版本时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, [
"update",
"--to",
"not-a-version",
]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/semver|--to/i);
});
});
@@ -13,7 +13,7 @@ describe("e2e: vision describe", () => {
"token-plan": {
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",
},
}),
);
@@ -40,6 +40,6 @@ describe("e2e: vision describe", () => {
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { model?: string } }>(stdout);
expect(data.request?.model).toBe("qwen3.8-max-preview");
expect(data.request?.model).toBe("qwen3.8-max");
});
});
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.13.0",
"version": "1.14.0",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -40,11 +40,13 @@
"check": "vp check"
},
"dependencies": {
"tar-stream": "catalog:",
"yaml": "^2.8.3",
"yauzl": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/tar-stream": "catalog:",
"@types/yauzl": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20260328.1",
"typescript": "^6.0.2",
+1
View File
@@ -7,6 +7,7 @@ export { recallCandidates } from "./recall.ts";
export { recallSemantic, isSemanticAvailable } from "./recall-semantic.ts";
export type { RecommendOptions } from "./recommend.ts";
export { buildDocLink, rankModels } from "./recommend.ts";
export { maybeSyncWikiData } from "./sync.ts";
export type { ModelSource } from "./sources/types.ts";
export type {
Budget,
+10 -31
View File
@@ -1,6 +1,5 @@
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../../config/paths.ts";
import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts";
import type { ModelSource } from "./types.ts";
@@ -13,12 +12,9 @@ function getCatalogDir(): string {
}
function getCatalogPath(): string {
return join(getCatalogDir(), MODELS_FILE);
}
function getMonorepoModelsDir(): string {
const coreDir = dirname(fileURLToPath(import.meta.url));
return join(coreDir, "../../../../../skills/bailian-docs-llm-wiki/models");
// Full-package layout keeps the `models/` subdir (raw/, wiki/, models/, …),
// so models.jsonl lives at <skill>/models/models.jsonl — not at the skill root.
return join(getCatalogDir(), "models", MODELS_FILE);
}
function fromJsonlRecord(raw: Record<string, unknown>): ModelProfile | null {
@@ -62,41 +58,24 @@ function readJsonlModels(filePath: string): ModelProfile[] {
return models;
}
function installFromMonorepo(): boolean {
const src = getMonorepoModelsDir();
if (!existsSync(join(src, MODELS_FILE))) return false;
const dest = getCatalogDir();
try {
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
return true;
} catch {
return false;
}
}
export interface CatalogSourceOptions {
onPrepareStart?: () => void;
}
export class CatalogSource implements ModelSource {
readonly name = "catalog";
private options: CatalogSourceOptions;
constructor(options?: CatalogSourceOptions) {
this.options = options ?? {};
}
// Options retained for API compatibility. Data is now always provisioned by
// the CLI postinstall hook and refreshed by advisor sync, so the previous
// `onPrepareStart` install callback is obsolete.
constructor(_options?: CatalogSourceOptions) {}
available(): boolean {
return existsSync(getCatalogPath());
}
async load(): Promise<ModelProfile[]> {
if (!this.available()) {
this.options.onPrepareStart?.();
const installed = installFromMonorepo();
if (!installed) return [];
}
if (!this.available()) return [];
return readJsonlModels(getCatalogPath());
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* sync.ts Wiki data sync (layer 2: triggered by recommend)
*
* Called via `maybeSyncWikiData()` during `bl advisor recommend`:
* 1. 12h throttle: skip if last check was less than 12h ago
* 2. Download skills/index.json from public-read OSS, compare bailian-docs-llm-wiki entry version
* 3. Same version only refresh lastChecked
* 4. Different version delegate to the shared skill install pipeline
* (installSkill: download + extract + SKILL.md validate + atomic swap;
* linkSkillToAgents: fan-out symlinks to detected agents;
* upsertSkillLockEntry: write lock WITH links so bl skill remove can reclaim correctly)
*
* Protocol: unified skill publishing protocol (FC publish-skills, all skills are isomorphic), entry point is
* skills/index.json, one content-addressed object per skill (sha256-<hex>.tar.br, brotli q6;
* legacy fallback skill.tar.br).
*
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Install, extraction,
* validation, fan-out and lock writing all reuse the skills/ module (same as bl skill add), symmetric
* with publisher tar.pack().
*
* Failure strategy: any step failure silently returns without updating lastChecked; next recommend retries immediately.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
import { detectInstalledAgents, fanOutSkillToAgents } from "../skills/agents.ts";
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
import { fetchSkillsIndex } from "../skills/registry.ts";
import type { SkillIndexEntry, SkillLockEntry } from "../skills/types.ts";
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
const STATE_FILE_NAME = "wiki-sync-state.json";
const MODELS_FILE = "models.jsonl";
const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h
/** Tighter than the interactive default: the silent channel must not stall `bl advisor recommend` */
const INDEX_TIMEOUT_MS = 3000;
interface SyncState {
lastChecked: number;
/** Content fingerprint of the last synced revision; the change-detection token */
contentHash: string;
}
function getCatalogDir(): string {
return join(getConfigDir(), SKILL_DIR_NAME);
}
/**
* Whether local Wiki data is ready. Uses `models.jsonl` as the existence signal, consistent with
* `CatalogSource.available()`: as long as the file advisor actually consumes exists,
* the data is considered available.
*/
function catalogDataExists(): boolean {
return existsSync(join(getCatalogDir(), "models", MODELS_FILE));
}
function getStatePath(): string {
return join(getConfigDir(), STATE_FILE_NAME);
}
function readState(): SyncState | null {
try {
return JSON.parse(readFileSync(getStatePath(), "utf-8")) as SyncState;
} catch {
return null;
}
}
function writeState(state: SyncState): void {
try {
writeFileSync(getStatePath(), JSON.stringify(state));
} catch {
/* Non-critical: if state write fails, next run will re-check */
}
}
/**
* Record this sync in skill-lock.json so the wiki skill shares the same ledger as bl skill
* (list shows installed instead of untracked; update/remove can manage it correctly).
* Includes fan-out links so bl skill remove can reclaim agent symlinks.
* Bookkeeping in the silent channel must be best-effort: failure does not affect sync results.
*/
function recordWikiInLock(lockEntry: SkillLockEntry): void {
try {
upsertSkillLockEntry(WIKI_SKILL_NAME, lockEntry);
} catch {
/* Bookkeeping failure does not block sync; next sync or bl skill add will fill it in */
}
}
/**
* Whether the lock still needs a wiki backfill: content fingerprint mismatch, or the
* record carries no fan-out links (postinstall writes contentHash only and never fans
* out, so agents would otherwise never see the wiki skill until content changes).
*/
function wikiLockNeedsBackfill(contentHash: string): boolean {
try {
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
return locked?.contentHash !== contentHash || !Array.isArray(locked.links);
} catch {
return true;
}
}
/** Fetch skills/index.json via the shared registry client and extract the wiki skill entry; returns null on any failure */
async function fetchIndexEntry(): Promise<SkillIndexEntry | null> {
try {
const index = await fetchSkillsIndex(INDEX_TIMEOUT_MS);
return index.skills[WIKI_SKILL_NAME] ?? null;
} catch {
return null;
}
}
/**
* Check and sync Wiki data. Runs silently; never throws.
* @returns Whether data was actually updated (for testing/debugging)
*/
export async function maybeSyncWikiData(): Promise<boolean> {
const state = readState();
const now = Date.now();
// 1. throttle gate: only skip when "within the 12h window" AND "local data actually exists".
// If data is missing (user deleted manually, postinstall failed but state remains, etc.),
// ignore throttle and sync immediately to ensure advisor has data.
if (state && now - state.lastChecked < THROTTLE_MS && catalogDataExists()) {
return false;
}
// 2. Fetch skills/index.json and get the wiki entry
const entry = await fetchIndexEntry();
if (!entry?.contentHash) return false; // On failure, do not write lastChecked; retry next time
// 3. Same content and local data exists: only refresh lastChecked, no re-download needed.
// Covers two cases: (a) state.contentHash === entry.contentHash → direct hit;
// (b) state missing but data intact (user or accident only deleted state) → write the fingerprint
// back to state, avoiding unnecessary download+extract.
// If data is missing or the fingerprint differs, falls through to step 4 for full download.
const dataOk = catalogDataExists();
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
writeState({ lastChecked: now, contentHash: entry.contentHash });
// Lock record missing/stale (e.g. postinstall wrote canonical only, without fan-out) → backfill
if (wikiLockNeedsBackfill(entry.contentHash)) {
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
const fanout = fanOutSkillToAgents(WIKI_SKILL_NAME, detectInstalledAgents(), previousLinks);
recordWikiInLock(buildSkillLockEntry(entry, fanout.links));
}
return false;
}
// 4. Different content or missing data: delegate to the shared skill install pipeline
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
try {
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
const record = await installSkillWithFanout(
WIKI_SKILL_NAME,
entry,
detectInstalledAgents(),
previousLinks,
);
recordWikiInLock(record.lockEntry);
} catch {
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
return false;
}
// 5. Success: write state
writeState({ lastChecked: now, contentHash: entry.contentHash });
return true;
}
+5 -2
View File
@@ -126,7 +126,10 @@ export class Client {
/** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */
uploadFile(source: string, model: string, opts: { signal?: AbortSignal } = {}): Promise<string> {
if (!isLocalFile(source)) return Promise.resolve(source);
return resolveFileUrl(source, this.requireApi().token, model, opts);
return resolveFileUrl(source, this.requireApi().token, model, {
...opts,
identity: this.deps.identity,
});
}
/**
@@ -233,7 +236,7 @@ export class Client {
const timeoutMs = this.deps.settings.timeout * 1000;
const res = await fetch(endpoint, {
method: opts.method,
headers: { ...headers, ...trackingHeaders() },
headers: { ...headers, ...trackingHeaders(this.deps.identity) },
body: bodyStr || undefined,
signal: AbortSignal.timeout(timeoutMs),
});
+19 -11
View File
@@ -1,23 +1,31 @@
/**
* Shared HTTP request headers for all outgoing requests.
*
* Centralises the `x-dashscope-source-config` header so every fetch call
* (both via the central http client and the bypass paths) uses the
* same values from a single source of truth.
* Centralises the `x-dashscope-source-config` header so Bailian/DashScope API
* transports use the same product identity. Generic npm, OSS, and result-file
* transfers deliberately do not send this gateway-consumed metadata.
*/
import type { Identity } from "../config/schema.ts";
export const CHANNEL = "bailian-cli";
export const TAGS = { t1: "public", t2: "" };
export type TrackingIdentity = Pick<Identity, "binName" | "version">;
export const SOURCE_CONFIG = JSON.stringify({
channel: CHANNEL,
tags: TAGS,
});
export function sourceConfig(identity: TrackingIdentity): string {
return JSON.stringify({
channel: CHANNEL,
tags: {
t1: "public",
t2: identity.binName,
t3: identity.version,
},
});
}
/** Standard tracking headers required on every outbound request. */
export function trackingHeaders(): Record<string, string> {
/** Tracking headers for Bailian/DashScope API requests. */
export function trackingHeaders(identity: TrackingIdentity): Record<string, string> {
return {
"x-dashscope-source-config": SOURCE_CONFIG,
"x-dashscope-source-config": sourceConfig(identity),
};
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { mapApiError } from "../errors/api.ts";
import { maskToken } from "../utils/token.ts";
import { SOURCE_CONFIG, trackingHeaders } from "./headers.ts";
import { sourceConfig, trackingHeaders } from "./headers.ts";
/** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */
export interface HttpDeps {
@@ -39,7 +39,7 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise<Respon
const headers: Record<string, string> = {
"User-Agent": `${deps.identity.clientName}/${deps.identity.version}`,
...trackingHeaders(),
...trackingHeaders(deps.identity),
...opts.headers,
};
@@ -59,7 +59,7 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise<Respon
console.error(`> ${opts.method ?? "GET"} ${opts.url}`);
const auth = headers["Authorization"];
if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`);
console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`);
console.error(`> x-dashscope-source-config: ${sourceConfig(deps.identity)}`);
}
const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000;
+1 -1
View File
@@ -34,7 +34,7 @@ export {
type ImageInputStyle,
type ImageSizeProfile,
} from "./image-routes.ts";
export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts";
export { CHANNEL, sourceConfig, trackingHeaders, type TrackingIdentity } from "./headers.ts";
export type { HttpDeps, RequestOpts } from "./http.ts";
export { request, requestJson } from "./http.ts";
export { createInstrumentedFetch, type FetchImplementation } from "./instrumented-fetch.ts";
@@ -50,7 +50,7 @@ export function createInstrumentedFetch(deps: HttpDeps): FetchImplementation {
headers.set("User-Agent", `${deps.identity.clientName}/${deps.identity.version}`);
}
if (isAlibabaCloudHost(url)) {
for (const [name, value] of Object.entries(trackingHeaders())) {
for (const [name, value] of Object.entries(trackingHeaders(deps.identity))) {
headers.set(name, value);
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ export class McpClient {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`,
...trackingHeaders(),
...trackingHeaders(this.deps.identity),
};
if (this.authToken) {
+1 -1
View File
@@ -10,7 +10,7 @@ interface ModelProfilePreset {
const MODEL_PROFILE_PRESETS: Readonly<Record<string, ModelProfilePreset>> = {
"token-plan": {
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
defaultTextModel: "qwen3.8-max-preview",
defaultTextModel: "qwen3.8-max",
defaultVideoModel: "happyhorse-1.1-t2v",
defaultImageToVideoModel: "happyhorse-1.1-i2v",
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
+2 -1
View File
@@ -47,7 +47,7 @@ export interface DatasetUploadParams {
export async function uploadDataset(
client: Client,
params: DatasetUploadParams,
): Promise<DatasetFile> {
): Promise<DatasetFile & { request_id?: string }> {
const { filePath, purpose = "fine-tune", signal } = params;
const stat = statSync(filePath);
const fileName = basename(filePath);
@@ -75,6 +75,7 @@ export async function uploadDataset(
size: body.bytes ?? stat.size,
purpose: body.purpose ?? purpose,
gmt_create: body.created_at ? new Date(body.created_at * 1000).toISOString() : undefined,
request_id: body.request_id,
};
}
// No id in response → upload reported HTTP 200 but produced no usable record
+14 -9
View File
@@ -9,7 +9,7 @@ import { existsSync, readFileSync, statSync } from "fs";
import { basename, extname } from "path";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { trackingHeaders } from "../client/headers.ts";
import { trackingHeaders, type TrackingIdentity } from "../client/headers.ts";
import { REGIONS } from "../config/schema.ts";
// Pinned to cn region; thread baseUrl through if overseas upload becomes a requirement.
@@ -36,6 +36,7 @@ interface UploadPolicyResponse {
async function getUploadPolicy(
apiKey: string,
model: string,
identity: TrackingIdentity,
signal?: AbortSignal,
): Promise<UploadPolicy> {
const url = `${UPLOAD_API}?action=getPolicy&model=${encodeURIComponent(model)}`;
@@ -44,7 +45,7 @@ async function getUploadPolicy(
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...trackingHeaders(),
...trackingHeaders(identity),
},
signal: policySignal.signal,
}).finally(policySignal.cleanup);
@@ -87,9 +88,6 @@ async function uploadToOSS(
const uploadSignal = combineWithTimeout(120_000, signal);
const res = await fetch(policy.upload_host, {
method: "POST",
headers: {
...trackingHeaders(),
},
body: form,
signal: uploadSignal.signal,
}).finally(uploadSignal.cleanup);
@@ -109,6 +107,7 @@ export interface UploadOptions {
apiKey: string;
model: string;
filePath: string;
identity: TrackingIdentity;
signal?: AbortSignal;
}
@@ -160,7 +159,7 @@ export function redactDataUri(input: string): string {
* The URL is valid for 48 hours.
*/
export async function uploadFile(opts: UploadOptions): Promise<string> {
const { apiKey, model, filePath, signal } = opts;
const { apiKey, model, filePath, identity, signal } = opts;
if (!existsSync(filePath)) {
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
@@ -171,7 +170,7 @@ export async function uploadFile(opts: UploadOptions): Promise<string> {
throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE);
}
const policy = await getUploadPolicy(apiKey, model, signal);
const policy = await getUploadPolicy(apiKey, model, identity, signal);
return uploadToOSS(policy, filePath, signal);
}
@@ -193,10 +192,16 @@ export async function resolveFileUrl(
input: string,
apiKey: string,
model: string,
opts: { signal?: AbortSignal } = {},
opts: { identity: TrackingIdentity; signal?: AbortSignal },
): Promise<string> {
if (!isLocalFile(input)) return input;
return uploadFile({ apiKey, model, filePath: input, signal: opts.signal });
return uploadFile({
apiKey,
model,
filePath: input,
identity: opts.identity,
signal: opts.signal,
});
}
function combineWithTimeout(
+2
View File
@@ -16,3 +16,5 @@ export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./telemetry/index.ts";
export * from "./advisor/index.ts";
export * from "./install/index.ts";
export * from "./skills/index.ts";
+107
View File
@@ -0,0 +1,107 @@
/**
* End-user binary download base (OSS). CI publishes release assets and rolling
* channel manifests here directly (tools/release/lib/oss-direct-upload.mjs);
* no external FC is involved.
*
* Layout under the base:
* v<version>/<asset>.zip immutable per-version binaries + SHA256SUMS
* manifest.json stable install/update pointer (rolling-manifest shape)
* latest.json stable alias; same body as manifest.json
* sync-release.json official channel/verify rolling pointer (all bailian-cli
* channel publishes overwrite this; npm dist-tag is separate)
*
* Legacy `{name}.json` files may still exist on CDN; install may resolve them, but
* release tooling no longer creates per-dist-tag manifests.
*
* Override with `BAILIAN_CLI_CDN`.
*/
export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release";
/** GitHub Releases base — used when writing manifests attached to gh release assets. */
export const GITHUB_RELEASES_BASE = "https://github.com/modelstudioai/cli/releases";
/** User-facing install entry (docs / update hints); asset downloads still use getCliCdnBase(). */
export const DEFAULT_INSTALL_SCRIPT_URL = "https://bailian.aliyun.com/cli/install.sh";
export const DEFAULT_INSTALL_PS1_URL = "https://bailian.aliyun.com/cli/install.ps1";
export function getCliCdnBase(): string {
const fromEnv = process.env.BAILIAN_CLI_CDN?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return DEFAULT_CLI_CDN_BASE;
}
/**
* Rolling manifest URL at the CDN base root.
* Stable (`latest` / `stable` / empty) `manifest.json`.
* Official verify line `sync-release.json` (`channel=sync-release`).
* Other names still map to `{channel}.json` for backward compatibility only.
* All share the same rolling-manifest shape from binary-build.
*/
export function channelManifestUrl(channel = "latest"): string {
const normalized = channel.trim();
if (!normalized || normalized === "latest" || normalized === "stable") {
return `${getCliCdnBase()}/manifest.json`;
}
return `${getCliCdnBase()}/${normalized}.json`;
}
/** Immutable per-version asset: `{base}/v{version}/{fileName}`. */
export function releaseAssetUrl(version: string, fileName: string): string {
const tag = version.startsWith("v") ? version : `v${version}`;
return `${getCliCdnBase()}/${tag}/${fileName}`;
}
/** Platform triple used in asset names: `bl-<ver>-<os>-<arch>[.exe]`. */
export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix: string } {
const platform = process.platform;
const arch = process.arch;
let os: string;
if (platform === "darwin") os = "darwin";
else if (platform === "linux") os = "linux";
else if (platform === "win32") os = "windows";
else {
throw new Error(`Unsupported platform for binary updates: ${platform}`);
}
let normalizedArch: string;
if (arch === "arm64") normalizedArch = "arm64";
else if (arch === "x64") normalizedArch = "x64";
else {
throw new Error(`Unsupported architecture for binary updates: ${arch}`);
}
if (os === "linux" && normalizedArch === "arm64") {
throw new Error(
"linux arm64 is not supported for binary updates; use: npm install -g bailian-cli",
);
}
if (os === "windows" && normalizedArch === "arm64") {
throw new Error(
"windows arm64 is not supported for binary updates; use: npm install -g bailian-cli",
);
}
const fileSuffix = platform === "win32" ? ".exe" : "";
return { os, arch: normalizedArch, fileSuffix };
}
/** Release download asset: `bl-<ver>-<os>-<arch>.zip`. */
export function binaryAssetFileName(
version: string,
os: string,
arch: string,
_exe = false,
): string {
return `bl-${version}-${os}-${arch}.zip`;
}
/** Uncompressed binary name inside the zip. */
export function binaryInnerFileName(
version: string,
os: string,
arch: string,
exe = false,
): string {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
+23
View File
@@ -0,0 +1,23 @@
export {
BINARY_PRODUCT_CLIENT_NAME,
detectInstallMethod,
getInstallMethod,
getUpdateInstallMethod,
isCompiledBinary,
writeInstallMethodSync,
type InstallMethod,
type InstallMethodIdentity,
} from "./method.ts";
export {
DEFAULT_CLI_CDN_BASE,
DEFAULT_INSTALL_PS1_URL,
DEFAULT_INSTALL_SCRIPT_URL,
GITHUB_RELEASES_BASE,
binaryAssetFileName,
binaryInnerFileName,
channelManifestUrl,
detectBinaryPlatform,
getCliCdnBase,
releaseAssetUrl,
} from "./cdn.ts";
export { extractZipEntryToFile } from "./unzip-asset.ts";
+131
View File
@@ -0,0 +1,131 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
/** How the CLI was installed on this machine. */
export type InstallMethod = "binary" | "npm" | "brew" | "winget" | "unknown";
/** Product that currently ships standalone binary artifacts (`bl` / `bailian`). */
export const BINARY_PRODUCT_CLIENT_NAME = "bailian-cli";
const INSTALL_METHOD_FILE = "install-method";
const VALID_METHODS = new Set<InstallMethod>(["binary", "npm", "brew", "winget", "unknown"]);
export type InstallMethodIdentity = {
clientName: string;
};
function installMethodPath(clientName?: string): string {
if (!clientName) return join(getConfigDir(), INSTALL_METHOD_FILE);
return join(getConfigDir(), `${INSTALL_METHOD_FILE}.${clientName}`);
}
/**
* True when running a Bun-compiled standalone executable
* rather than via the Node/npm entry shim.
*
* Binary entrypoints set `BAILIAN_COMPILED=1` before other code runs.
*/
export function isCompiledBinary(): boolean {
if (process.env.BAILIAN_COMPILED === "1") return true;
const execPath = process.execPath.replaceAll("\\", "/");
if (/(^|\/)node(\.exe)?$/i.test(execPath) || execPath.includes("/node/")) return false;
if (/(^|\/)bun(\.exe)?$/i.test(execPath) || execPath.includes("/.bun/")) return false;
return /\/(bl|bailian)(\.exe)?$/i.test(execPath);
}
function parseInstallMethod(raw: string | undefined): InstallMethod | null {
if (!raw) return null;
const value = raw.trim().toLowerCase() as InstallMethod;
return VALID_METHODS.has(value) ? value : null;
}
function readInstallMethodFile(path: string): InstallMethod | null {
try {
const raw = readFileSync(path, "utf-8");
return parseInstallMethod(raw.split("\n")[0]);
} catch {
return null;
}
}
/** Infer install method when no marker file / env override is present. */
export function detectInstallMethod(): InstallMethod {
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
if (fromEnv) return fromEnv;
if (isCompiledBinary()) {
const execPath = process.execPath.replaceAll("\\", "/");
if (execPath.includes("/Cellar/") || execPath.includes("/homebrew/")) return "brew";
return "binary";
}
return "npm";
}
/**
* Read the persisted install method, falling back to detection.
*
* When `identity` is provided, prefer `install-method.<clientName>`.
* Legacy `~/.bailian/install-method` is only consulted for `bailian-cli`
* so other products (e.g. kscli) are not polluted by a shared binary marker.
*/
export function getInstallMethod(identity?: InstallMethodIdentity): InstallMethod {
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
if (fromEnv) return fromEnv;
if (identity?.clientName) {
const productMethod = readInstallMethodFile(installMethodPath(identity.clientName));
if (productMethod) return productMethod;
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
const legacyMethod = readInstallMethodFile(installMethodPath());
if (legacyMethod) return legacyMethod;
}
return detectInstallMethod();
}
const legacyMethod = readInstallMethodFile(installMethodPath());
if (legacyMethod) return legacyMethod;
return detectInstallMethod();
}
/**
* Install method for update / auto-update routing.
* Only `bailian-cli` may follow the binary channel; other products always use npm
* even if env or a mistaken marker claims `binary`.
*/
export function getUpdateInstallMethod(identity: {
clientName: string;
npmPackage: string;
}): InstallMethod {
const method = getInstallMethod(identity);
if (method === "binary" && identity.npmPackage !== BINARY_PRODUCT_CLIENT_NAME) {
return "npm";
}
return method;
}
/**
* Persist install method under `~/.bailian/install-method.<clientName>` (best-effort).
* For `bailian-cli`, also write the legacy `install-method` file for older readers.
*/
export function writeInstallMethodSync(
method: InstallMethod,
identity: InstallMethodIdentity = { clientName: BINARY_PRODUCT_CLIENT_NAME },
): void {
try {
const dir = getConfigDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
writeFileSync(installMethodPath(identity.clientName), `${method}\n`, { mode: 0o600 });
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
writeFileSync(installMethodPath(), `${method}\n`, { mode: 0o600 });
}
} catch {
/* best effort */
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Extract a single file entry from a ZIP into `destPath` (overwrites).
* Uses yauzl (already a core dependency for dataset ZIP validation).
*/
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import * as yauzl from "yauzl";
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (error, zipfile) => {
if (error || !zipfile) {
reject(error ?? new Error(`Failed to open zip: ${zipPath}`));
return;
}
resolve(zipfile);
});
});
}
function entryBaseName(fileName: string): string {
const normalized = fileName.replace(/\\/g, "/");
return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
}
/**
* Extract `entryName` (or the first non-directory entry) from `zipPath` to `destPath`.
* Returns the archive entry basename that was extracted.
*/
export async function extractZipEntryToFile(
zipPath: string,
destPath: string,
entryName?: string,
): Promise<string> {
const zipfile = await openZip(zipPath);
return new Promise((resolve, reject) => {
let settled = false;
const fail = (error: unknown) => {
if (settled) return;
settled = true;
try {
zipfile.close();
} catch {
/* ignore */
}
reject(error instanceof Error ? error : new Error(String(error)));
};
const succeed = (baseName: string) => {
if (settled) return;
settled = true;
try {
zipfile.close();
} catch {
/* ignore */
}
resolve(baseName);
};
zipfile.on("error", fail);
zipfile.on("end", () => {
if (settled) return;
fail(
new Error(
entryName
? `Zip entry not found: ${entryName} in ${zipPath}`
: `Zip has no file entries: ${zipPath}`,
),
);
});
zipfile.on("entry", (current: yauzl.Entry) => {
if (settled) return;
const name = current.fileName.replace(/\\/g, "/");
if (name.endsWith("/")) {
zipfile.readEntry();
return;
}
const base = entryBaseName(name);
const isMatch = entryName ? name === entryName || base === entryName : true;
if (!isMatch) {
zipfile.readEntry();
return;
}
zipfile.openReadStream(current, (streamError, readStream) => {
if (streamError || !readStream) {
fail(streamError ?? new Error(`Failed to read zip entry: ${current.fileName}`));
return;
}
void (async () => {
try {
await mkdir(dirname(destPath), { recursive: true });
await pipeline(readStream, createWriteStream(destPath));
succeed(base);
} catch (error) {
fail(error);
}
})();
});
});
zipfile.readEntry();
});
}
+437
View File
@@ -0,0 +1,437 @@
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readlinkSync,
rmSync,
symlinkSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
import { getSkillsDir } from "./lock.ts";
/**
* Agent fan-out: after a skill lands in the canonical dir (~/.bailian/skills/<name>),
* symlink it into each detected AI agent's global skills directory so that a single
* install becomes visible across all agents.
*
* Detection semantics: if the agent's config dir exists agent is installed create link;
* otherwise skip (never create ~/.xxx dirs that pollute home). When a new agent is installed
* later, any subsequent `bl skill add/update` will fill in missing links (self-healing).
*/
export interface AgentTarget {
id: string;
displayName: string;
/** Global directory where this agent reads skills from */
skillsDir: string;
/** 任一存在即判定"本机装了该 agent" */
detectDirs: string[];
}
/**
* Computed on each call (depends on homedir / XDG_CONFIG_HOME / cwd; easy to override in tests).
* Registry mirrors the vercel-labs/skills agent list, minus agents that cannot participate in
* global symlink fan-out (eve: no global dir, upstream forces direct writes; promptscript:
* project-only). Shared-dir agents (Cline/Warp/Zed/Kimi/ read ~/.agents/skills; Amp/Replit
* read $XDG_CONFIG_HOME/agents/skills) are folded into the universal pseudo-agents' detectDirs.
*/
export function getAgentTargets(): AgentTarget[] {
const home = homedir();
const cwd = process.cwd();
const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
const simple = (id: string, displayName: string, dir: string): AgentTarget => ({
id,
displayName,
skillsDir: join(home, dir, "skills"),
detectDirs: [join(home, dir)],
});
/** Config base dir that can be relocated via the agent's official env var */
const envBase = (envValue: string | undefined, fallbackDir: string): string => {
const trimmed = envValue?.trim();
return trimmed ? trimmed : join(home, fallbackDir);
};
/** Target derived from an absolute base dir (detection and skills dir stay in sync) */
const fromBase = (id: string, displayName: string, baseDir: string): AgentTarget => ({
id,
displayName,
skillsDir: join(baseDir, "skills"),
detectDirs: [baseDir],
});
// OpenClaw was renamed over time (.openclaw → .clawdbot → .moltbot): link into the first
// home that actually exists, detect any of them
const openclawCandidates = [".openclaw", ".clawdbot", ".moltbot"].map((dir) => join(home, dir));
const openclawHome = openclawCandidates.find((dir) => existsSync(dir)) ?? openclawCandidates[0]!;
// Zed's config_dir(): XDG on Linux/macOS, %APPDATA% on Windows, Flatpak override
const zedDetectDirs = [join(xdgConfig, "zed")];
const zedAppData = process.env.APPDATA?.trim();
if (zedAppData) zedDetectDirs.push(join(zedAppData, "Zed"));
const zedFlatpakConfig = process.env.FLATPAK_XDG_CONFIG_HOME?.trim();
if (zedFlatpakConfig) zedDetectDirs.push(join(zedFlatpakConfig, "zed"));
const codexHome = envBase(process.env.CODEX_HOME, ".codex");
return [
// universal pseudo-agent: ~/.agents/skills is a shared dir read by Cline, Warp, Zed,
// Kimi Code, Dexto, Firebender, Loaf, …
{
id: "universal",
displayName: "Universal (~/.agents/skills)",
skillsDir: join(home, ".agents", "skills"),
detectDirs: [
join(home, ".agents"),
join(home, ".cline"),
join(home, ".dexto"),
join(home, ".firebender"),
join(home, ".kimi-code"),
join(home, ".kimi"),
join(home, ".loaf"),
join(home, ".warp"),
...zedDetectDirs,
],
},
// XDG variant: $XDG_CONFIG_HOME/agents/skills, shared dir read by Amp-style agents; Replit
// also reads it and is detected project-locally via cwd/.replit
{
id: "universal-xdg",
displayName: "Universal (XDG agents/skills)",
skillsDir: join(xdgConfig, "agents", "skills"),
detectDirs: [join(xdgConfig, "agents"), join(xdgConfig, "amp"), join(cwd, ".replit")],
},
simple("adal", "AdaL", ".adal"),
simple("aider-desk", "AiderDesk", ".aider-desk"),
simple("antigravity", "Antigravity", ".gemini/antigravity"),
simple("antigravity-cli", "Antigravity CLI", ".gemini/antigravity-cli"),
{
id: "astrbot",
displayName: "AstrBot",
skillsDir: join(home, ".astrbot", "data", "skills"),
detectDirs: [join(cwd, "data", "skills"), join(home, ".astrbot")],
},
fromBase("autohand-code", "Autohand Code CLI", envBase(process.env.AUTOHAND_HOME, ".autohand")),
simple("augment", "Augment", ".augment"),
simple("bob", "IBM Bob", ".bob"),
fromBase("claude-code", "Claude Code", envBase(process.env.CLAUDE_CONFIG_DIR, ".claude")),
simple("codearts-agent", "CodeArts Agent", ".codeartsdoer"),
{
id: "codebuddy",
displayName: "CodeBuddy",
skillsDir: join(home, ".codebuddy", "skills"),
detectDirs: [join(cwd, ".codebuddy"), join(home, ".codebuddy")],
},
simple("codemaker", "Codemaker", ".codemaker"),
simple("codestudio", "Code Studio", ".codestudio"),
{
id: "codex",
displayName: "Codex",
skillsDir: join(codexHome, "skills"),
detectDirs: [codexHome, "/etc/codex"],
},
simple("command-code", "Command Code", ".commandcode"),
{
id: "continue",
displayName: "Continue",
skillsDir: join(home, ".continue", "skills"),
detectDirs: [join(cwd, ".continue"), join(home, ".continue")],
},
simple("cortex", "Cortex Code", ".snowflake/cortex"),
simple("crush", "Crush", ".config/crush"),
simple("cursor", "Cursor", ".cursor"),
{
id: "deepagents",
displayName: "Deep Agents",
skillsDir: join(home, ".deepagents", "agent", "skills"),
detectDirs: [join(home, ".deepagents")],
},
{
id: "devin",
displayName: "Devin for Terminal",
skillsDir: join(xdgConfig, "devin", "skills"),
detectDirs: [join(xdgConfig, "devin")],
},
simple("droid", "Droid", ".factory"),
simple("forgecode", "ForgeCode", ".forge"),
simple("gemini-cli", "Gemini CLI", ".gemini"),
simple("github-copilot", "GitHub Copilot", ".copilot"),
{
id: "goose",
displayName: "Goose",
skillsDir: join(xdgConfig, "goose", "skills"),
detectDirs: [join(xdgConfig, "goose")],
},
fromBase("grok", "Grok Build", envBase(process.env.GROK_HOME, ".grok")),
fromBase("hermes", "Hermes Agent", envBase(process.env.HERMES_HOME, ".hermes")),
simple("iflow-cli", "iFlow CLI", ".iflow"),
simple("inference-sh", "inference.sh", ".inferencesh"),
{
id: "jazz",
displayName: "Jazz",
skillsDir: join(home, ".jazz", "skills"),
detectDirs: [join(home, ".jazz"), join(cwd, ".jazz")],
},
simple("junie", "Junie", ".junie"),
simple("kilo", "Kilo Code", ".kilocode"),
{
id: "kimchi",
displayName: "Kimchi",
skillsDir: join(home, ".config", "kimchi", "harness", "skills"),
detectDirs: [join(home, ".config", "kimchi")],
},
simple("kiro-cli", "Kiro CLI", ".kiro"),
simple("kode", "Kode", ".kode"),
simple("lingma", "Lingma", ".lingma"),
simple("mcpjam", "MCPJam", ".mcpjam"),
{
id: "minimax-code",
displayName: "MiniMax Code",
skillsDir: join(home, ".minimax", "skills"),
detectDirs: [join(home, ".minimax"), "/Applications/MiniMax Code.app"],
},
fromBase("mistral-vibe", "Mistral Vibe", envBase(process.env.VIBE_HOME, ".vibe")),
simple("moxby", "Moxby", ".moxby"),
simple("mux", "Mux", ".mux"),
simple("neovate", "Neovate", ".neovate"),
{
id: "opencode",
displayName: "OpenCode",
skillsDir: join(xdgConfig, "opencode", "skills"),
detectDirs: [join(xdgConfig, "opencode")],
},
{
id: "openclaw",
displayName: "OpenClaw",
skillsDir: join(openclawHome, "skills"),
detectDirs: openclawCandidates,
},
simple("openhands", "OpenHands", ".openhands"),
simple("ona", "Ona", ".ona"),
simple("pi", "Pi", ".pi/agent"),
simple("pochi", "Pochi", ".pochi"),
simple("qoder", "Qoder", ".qoder"),
simple("qoder-cn", "Qoder CN", ".qoder-cn"),
simple("qwen-code", "Qwen Code", ".qwen"),
simple("reasonix", "Reasonix", ".reasonix"),
simple("rovodev", "Rovo Dev", ".rovodev"),
simple("roo", "Roo Code", ".roo"),
{
id: "tabnine-cli",
displayName: "Tabnine CLI",
skillsDir: join(home, ".tabnine", "agent", "skills"),
detectDirs: [join(home, ".tabnine")],
},
simple("terramind", "Terramind", ".terramind"),
simple("tinycloud", "Tinycloud", ".tinycloud"),
simple("trae", "Trae", ".trae"),
simple("trae-cn", "Trae CN", ".trae-cn"),
simple("windsurf", "Windsurf", ".codeium/windsurf"),
{
id: "zcode",
displayName: "ZCode",
skillsDir: join(home, ".zcode", "skills"),
detectDirs: [join(home, ".zcode"), "/Applications/ZCode.app"],
},
// Zenflow reads the same ~/.zencoder/skills dir, so one target covers both
simple("zencoder", "Zencoder", ".zencoder"),
];
}
export function detectInstalledAgents(): AgentTarget[] {
return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir)));
}
/** Path equality that respects the host filesystem's case rules (Windows is case-insensitive) */
function samePath(left: string, right: string): boolean {
if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase();
return left === right;
}
/** Whether absPath is the canonical skills dir or lives inside it (case-aware on Windows) */
function isUnderCanonicalDir(absPath: string): boolean {
const skillsDir = getSkillsDir();
if (process.platform === "win32") {
const lowerPath = absPath.toLowerCase();
const lowerDir = skillsDir.toLowerCase();
return lowerPath === lowerDir || lowerPath.startsWith(lowerDir + sep);
}
return absPath === skillsDir || absPath.startsWith(skillsDir + sep);
}
/** Whether linkPath is managed by this tool: a symlink whose resolved target falls within the canonical skills dir */
function isManagedLink(linkPath: string): boolean {
try {
if (!lstatSync(linkPath).isSymbolicLink()) return false;
const target = readlinkSync(linkPath);
const abs = isAbsolute(target) ? target : resolve(dirname(linkPath), target);
return isUnderCanonicalDir(abs);
} catch {
return false;
}
}
/**
* Whether linkPath is a copy-fallback artifact recorded in the lock: a real directory
* (not a symlink) at a path this tool previously wrote when symlink creation failed
* (typical: Windows without Developer Mode). Only recorded paths qualify foreign
* directories are never touched.
*/
function isRecordedCopy(linkPath: string, recordedLinks: string[]): boolean {
if (!recordedLinks.some((recorded) => samePath(recorded, linkPath))) return false;
try {
return lstatSync(linkPath).isDirectory();
} catch {
return false;
}
}
export interface LinkResult {
agent: string;
path: string;
mode: "symlink" | "copy" | "skipped";
reason?: string;
}
/** Fixed skip reason for foreign paths; fanOutSkillToAgents keys ledger drops off this value */
const UNMANAGED_SKIP_REASON = "existing file/dir not managed by bl skill";
/**
* Fan out a skill from canonical to each agent's skills dir.
* Stale links created by this tool are rebuilt; recorded copy-fallback artifacts
* (real dirs at paths present in recordedLinks) are replaced with fresh content;
* any other existing files/dirs are always skipped (never delete user content).
* Falls back to copy when symlink fails (e.g. Windows without Developer Mode).
*/
export function linkSkillToAgents(
name: string,
agents: AgentTarget[] = detectInstalledAgents(),
recordedLinks: string[] = [],
): LinkResult[] {
const target = join(getSkillsDir(), name);
const results: LinkResult[] = [];
for (const agent of agents) {
const linkPath = join(agent.skillsDir, name);
try {
let existing = false;
try {
lstatSync(linkPath); // existsSync returns false for dangling symlinks; must use lstat
existing = true;
} catch {
/* does not exist */
}
if (existing) {
if (isManagedLink(linkPath)) {
rmSync(linkPath);
} else if (isRecordedCopy(linkPath, recordedLinks)) {
// Copy-fallback artifact from a previous install → replace so updates
// reach agents that have no symlink permission
rmSync(linkPath, { recursive: true, force: true });
} else {
results.push({
agent: agent.id,
path: linkPath,
mode: "skipped",
reason: UNMANAGED_SKIP_REASON,
});
continue;
}
}
mkdirSync(agent.skillsDir, { recursive: true });
try {
symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
results.push({ agent: agent.id, path: linkPath, mode: "symlink" });
} catch {
// No symlink permission (typical: Windows non-Developer Mode) → fall back to copy
cpSync(target, linkPath, { recursive: true });
results.push({ agent: agent.id, path: linkPath, mode: "copy" });
}
} catch (err) {
results.push({
agent: agent.id,
path: linkPath,
mode: "skipped",
reason: err instanceof Error ? err.message : String(err),
});
}
}
return results;
}
/**
* Fan-out workflow: link to agents AND compute the next lock ledger in one step.
* Shared by bl skill add/update (fresh install and self-healing) and advisor wiki sync,
* so every channel applies the same ledger-merge rules.
*/
export interface FanoutOutcome {
results: LinkResult[];
/** Agent ids that actually received a link/copy this run (skipped ones excluded) */
linkedAgents: string[];
/** Next lock links ledger; see merge rules in fanOutSkillToAgents */
links: string[];
}
/**
* Fan out and merge the resulting paths with the previously recorded ledger:
* - effective paths from this run are recorded;
* - recorded paths NOT visited this run are preserved (agent uninstalled/undetected
* the artifact may still exist and must stay reclaimable by bl skill remove);
* - recorded paths that failed transiently this run are preserved for the same reason;
* - recorded paths confirmed foreign this run (unmanaged skip) are dropped the user
* replaced our artifact, and keeping the record would let remove delete user content.
*/
export function fanOutSkillToAgents(
name: string,
agents: AgentTarget[] = detectInstalledAgents(),
recordedLinks: string[] = [],
): FanoutOutcome {
const results = linkSkillToAgents(name, agents, recordedLinks);
const effective = results.filter((result) => result.mode !== "skipped");
const effectivePaths = effective.map((result) => result.path);
const confirmedForeign = results
.filter((result) => result.mode === "skipped" && result.reason === UNMANAGED_SKIP_REASON)
.map((result) => result.path);
const preserved = recordedLinks.filter(
(recorded) =>
!effectivePaths.some((path) => samePath(path, recorded)) &&
!confirmedForeign.some((path) => samePath(path, recorded)),
);
return {
results,
linkedAgents: effective.map((result) => result.agent),
links: [...effectivePaths, ...preserved],
};
}
/**
* Reclaim fan-out artifacts for a skill across all agent dirs.
* Symlinks pointing to canonical are removed (including historical links not in lock,
* via defensive scan of the full registry); real directories are only removed if recorded
* in lock (copy-fallback artifacts). A single failure does not block the rest.
*/
export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] {
const removed: string[] = [];
const candidates = new Set(recordedLinks);
for (const agent of getAgentTargets()) candidates.add(join(agent.skillsDir, name));
for (const linkPath of candidates) {
try {
let stat;
try {
stat = lstatSync(linkPath);
} catch {
continue;
}
if (stat.isSymbolicLink()) {
if (isManagedLink(linkPath)) {
rmSync(linkPath);
removed.push(linkPath);
}
} else if (recordedLinks.some((recorded) => samePath(recorded, linkPath))) {
rmSync(linkPath, { recursive: true, force: true });
removed.push(linkPath);
}
} catch {
/* single failure does not block remaining cleanup */
}
}
return removed;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* tar.br archive extraction and atomic swap shared by advisor wiki sync and `bl skill` install.
* Symmetric with the publisher (FC skills-publish.mjs: tar.pack + brotli); uses only Node built-in
* zlib + tar-stream, no extra decompression dependencies.
*/
import {
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
} from "node:fs";
import { createHash } from "node:crypto";
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";
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
export function isSafeEntryName(name: string): boolean {
// Reject backslashes outright: on Windows path.join expands backslash-separated
// ".." segments and a leading "\" resolves to the drive root, so such names can
// escape the extraction dir even though they pass the "/"-based checks below.
// The publisher always packs with "/" separators, so this never rejects legit archives.
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 (per-entry path safety check). */
export async function extractTarBr(tarBrBuffer: Buffer, destDir: string): Promise<void> {
const extract = tar.extract();
extract.on("entry", (header, stream, next) => {
if (!isSafeEntryName(header.name)) {
// Use destroy so the pipeline rejects with this error; silence the entry stream
// to avoid its companion error becoming an unhandled exception
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:
* regular files sorted by "/"-separated relative path (code-unit order, same as the
* publisher's byte-order sort for ASCII paths), sha256 accumulating relPath + bytes.
* Symmetric with computeContentHash in FC skills-publish.mjs.
*/
export function computeDirContentHash(dir: string): string {
const relPaths: string[] = [];
const walk = (sub: string): void => {
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: replace destDir with the extracted content from tmpDir.
* tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic.
*/
export function atomicSwap(tmpDir: string, destDir: string): void {
mkdirSync(dirname(destDir), { recursive: true });
const backup = `${destDir}.old-${Date.now()}`;
if (existsSync(destDir)) renameSync(destDir, backup);
try {
renameSync(tmpDir, destDir);
} catch (err) {
// Swap failed → roll back the old directory to avoid leaving a hole
if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir);
throw err;
}
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
}
+47
View File
@@ -0,0 +1,47 @@
// bl skill management: OSS unified publishing protocol client + local install/fan-out/status reconciliation.
export type {
SkillIndexEntry,
SkillsIndex,
SkillLockEntry,
SkillLockFile,
SkillStatus,
SkillStatusRow,
} from "./types.ts";
export {
getSkillRegistryBaseUrl,
fetchSkillsIndex,
downloadSkillAsset,
resolveAssetFileName,
} from "./registry.ts";
export {
getSkillsDir,
getSkillLockPath,
emptySkillLock,
readSkillLock,
writeSkillLock,
upsertSkillLockEntry,
} from "./lock.ts";
export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts";
export { parseSkillNames } from "./names.ts";
export { validateSkillDir, type SkillMeta } from "./validate.ts";
export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from "./extract.ts";
export {
getAgentTargets,
detectInstalledAgents,
linkSkillToAgents,
fanOutSkillToAgents,
unlinkSkillFromAgents,
type AgentTarget,
type LinkResult,
type FanoutOutcome,
} from "./agents.ts";
export {
installSkill,
installSkillFromBuffer,
installSkillWithFanout,
buildSkillLockEntry,
removeSkillDir,
type InstalledSkill,
type SkillInstallRecord,
} from "./installer.ts";
export { listSkillDirsOnDisk, computeSkillStatuses } from "./status.ts";
+132
View File
@@ -0,0 +1,132 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { detectInstalledAgents, fanOutSkillToAgents, type AgentTarget } from "./agents.ts";
import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts";
import { getSkillsDir } from "./lock.ts";
import { downloadSkillAsset } from "./registry.ts";
import { isSafeSkillName } from "./sanitize.ts";
import { validateSkillDir, type SkillMeta } from "./validate.ts";
import type { SkillIndexEntry, SkillLockEntry } from "./types.ts";
/**
* Skill installer: download extract to tmpdir (with tar-slip check) validate SKILL.md
* atomic swap into canonical. Canonical is only touched after all validations pass; on any failure
* the current installation is preserved and temp artifacts are cleaned up in finally.
*/
export interface InstalledSkill {
name: string;
path: string;
meta: SkillMeta;
}
function assertSafeName(name: string): void {
if (!isSafeSkillName(name)) {
throw new BailianError(
`Invalid skill name: ${name}`,
ExitCode.GENERAL,
"Skill name contains path separators, traversal sequences, or other illegal characters; refusing to write to disk",
);
}
}
/** Install from an in-memory tar.br archive (the download-and-onwards half of installSkill; test-friendly) */
export async function installSkillFromBuffer(
name: string,
tarBrBuffer: Buffer,
expectedContentHash?: string,
): Promise<InstalledSkill> {
assertSafeName(name);
const skillsDir = getSkillsDir();
const dest = join(skillsDir, name);
// Same-volume temp dir: extract here then rename; cross-device rename would EXDEV
const tmpDir = join(skillsDir, `.tmp-${name}-${process.pid}-${Date.now()}`);
try {
mkdirSync(tmpDir, { recursive: true });
await extractTarBr(tarBrBuffer, tmpDir);
// Integrity check before touching canonical: recompute the publisher fingerprint over
// the extracted files; on mismatch the current installation is left untouched
if (expectedContentHash?.startsWith("sha256:")) {
const actualContentHash = computeDirContentHash(tmpDir);
if (actualContentHash !== expectedContentHash) {
throw new BailianError(
`Skill ${name} failed integrity check: index says ${expectedContentHash}, archive is ${actualContentHash}`,
ExitCode.GENERAL,
"Downloaded archive does not match the index fingerprint (registry may be mid-publish); retry later",
);
}
}
const meta = validateSkillDir(tmpDir, name);
atomicSwap(tmpDir, dest);
return { name, path: dest, meta };
} finally {
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
}
}
/** Install a single skill by index entry (download + validate + write to disk) */
export async function installSkill(name: string, entry: SkillIndexEntry): Promise<InstalledSkill> {
if (entry.compression && entry.compression !== "tar.br") {
throw new BailianError(
`Skill ${name} uses unsupported compression format: ${entry.compression}`,
ExitCode.GENERAL,
"Upgrade bailian-cli to the latest version and retry",
);
}
const buffer = await downloadSkillAsset(name, entry);
return installSkillFromBuffer(name, buffer, entry.contentHash);
}
/** Remove the skill directory under canonical; returns whether it was actually deleted (dir absent → false) */
export function removeSkillDir(name: string): boolean {
assertSafeName(name);
const dest = join(getSkillsDir(), name);
if (!existsSync(dest)) return false;
rmSync(dest, { recursive: true, force: true });
return true;
}
/**
* Build a skill-lock entry from an index entry + effective fan-out link paths.
* Single source of truth for the "installation fact" shape shared by bl skill add/update,
* advisor wiki sync, and any future install channel.
*/
export function buildSkillLockEntry(entry: SkillIndexEntry, links: string[]): SkillLockEntry {
return {
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
installedAt: new Date().toISOString(),
sourceType: "oss",
...(entry.description ? { description: entry.description } : {}),
links,
};
}
export interface SkillInstallRecord {
/** Ready-to-persist lock entry (links = effective fan-out paths) */
lockEntry: SkillLockEntry;
/** Ids of agents that actually received a link/copy (skipped ones excluded) */
linkedAgents: string[];
}
/**
* Full install workflow for one skill: install into canonical, fan out to agents, and build
* the lock entry recording the merged links ledger. Callers decide how to persist the lock
* entry (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels).
* recordedLinks = the skill's previously recorded fan-out paths from the lock; lets the
* fan-out replace copy-fallback artifacts and keeps unvisited paths reclaimable.
*/
export async function installSkillWithFanout(
name: string,
entry: SkillIndexEntry,
agents: AgentTarget[] = detectInstalledAgents(),
recordedLinks: string[] = [],
): Promise<SkillInstallRecord> {
await installSkill(name, entry);
const fanout = fanOutSkillToAgents(name, agents, recordedLinks);
return {
lockEntry: buildSkillLockEntry(entry, fanout.links),
linkedAgents: fanout.linkedAgents,
};
}
+59
View File
@@ -0,0 +1,59 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
import type { SkillLockEntry, SkillLockFile } from "./types.ts";
/**
* Local skill state: canonical directory + skill-lock.json.
*
* The lock only records "installation facts" (version, timestamp, fan-out links) and never
* caches the remote index list/update diffs are always "live remote index vs lock".
* Paths follow the config.json directory logic (BAILIAN_CONFIG_DIR can redirect everything).
*/
export function getSkillsDir(): string {
return join(getConfigDir(), "skills");
}
export function getSkillLockPath(): string {
return join(getSkillsDir(), "skill-lock.json");
}
export function emptySkillLock(): SkillLockFile {
return { version: 1, skills: {} };
}
/**
* Read installation records. Returns an empty lock when the file is absent (first install),
* corrupted, or has an unrecognized version an empty lock is a valid initial state, not an
* error; subsequent install actions will rebuild correct records.
*/
export function readSkillLock(): SkillLockFile {
const path = getSkillLockPath();
if (!existsSync(path)) return emptySkillLock();
try {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as SkillLockFile;
if (parsed?.version !== 1 || typeof parsed.skills !== "object" || parsed.skills === null) {
return emptySkillLock();
}
return parsed;
} catch {
return emptySkillLock();
}
}
export function writeSkillLock(lock: SkillLockFile): void {
mkdirSync(getSkillsDir(), { recursive: true });
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
}
/**
* Merge-update a single skill's installation record (read-modify-write).
* Shallow-merges with the existing entry: fields not provided in patch (typically links
* agent fan-out records) are preserved, preventing "install-only, no fan-out" sync channels
* like postinstall/advisor from overwriting link records established by bl skill add.
*/
export function upsertSkillLockEntry(name: string, patch: SkillLockEntry): void {
const lock = readSkillLock();
lock.skills[name] = { ...lock.skills[name], ...patch };
writeSkillLock(lock);
}
+30
View File
@@ -0,0 +1,30 @@
import { UsageError } from "../errors/base.ts";
/**
* Parse --name: `all` or a comma-separated list of skill names (deduplicated, trimmed).
* `all` cannot be mixed with specific names.
*/
export function parseSkillNames(raw: string | undefined, defaultAll: boolean): string[] | "all" {
const value = (raw ?? (defaultAll ? "all" : "")).trim();
if (!value) {
throw new UsageError("--name cannot be empty", "Use --name all or --name skill-a,skill-b");
}
const parts = [
...new Set(
value
.split(",")
.map((part) => part.trim())
.filter(Boolean),
),
];
if (parts.includes("all")) {
if (parts.length > 1) {
throw new UsageError(
"--name all cannot be mixed with specific skill names",
"Use either all or a comma-separated list of names",
);
}
return "all";
}
return parts;
}
+112
View File
@@ -0,0 +1,112 @@
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { SkillIndexEntry, SkillsIndex } from "./types.ts";
/**
* Skill registry client: public-read OSS, pure HTTPS GET, zero credentials (usable with auth: "none").
* Defaults to the skills/ prefix of the bailian-wiki bucket; override with BAILIAN_SKILL_REGISTRY_URL
* for canary/private mirror scenarios.
*/
const DEFAULT_REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
const INDEX_TIMEOUT_MS = 10_000;
const ASSET_TIMEOUT_MS = 120_000;
export function getSkillRegistryBaseUrl(): string {
const override = process.env.BAILIAN_SKILL_REGISTRY_URL?.trim();
return (override || DEFAULT_REGISTRY_BASE_URL).replace(/\/+$/, "");
}
/**
* Fetch the remote skill index. No local caching the diff comparison is always
* "live remote index vs local skill-lock.json".
* Silent background channels (advisor sync) may pass a tighter timeout than the interactive default.
*/
export async function fetchSkillsIndex(timeoutMs: number = INDEX_TIMEOUT_MS): Promise<SkillsIndex> {
const url = `${getSkillRegistryBaseUrl()}/index.json`;
let res: Response;
try {
res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
} catch (err) {
throw new BailianError(
`Cannot access skill registry: ${url}`,
ExitCode.NETWORK,
"Check network connectivity; if using a private mirror, verify BAILIAN_SKILL_REGISTRY_URL configuration",
{ cause: err },
);
}
if (!res.ok) {
throw new BailianError(
`Skill registry returned HTTP ${res.status}: ${url}`,
ExitCode.NETWORK,
res.status === 404
? "Skill index not yet published or registry URL is incorrect; confirm the publisher has generated index.json"
: "Remote error, retry later",
);
}
let parsed: unknown;
try {
parsed = await res.json();
} catch (err) {
throw new BailianError(
"Skill index index.json is not valid JSON",
ExitCode.GENERAL,
"Remote may be in the middle of publishing, retry later",
{ cause: err },
);
}
const index = parsed as SkillsIndex;
if (
typeof index !== "object" ||
index === null ||
typeof index.skills !== "object" ||
index.skills === null
) {
throw new BailianError(
"Skill index index.json has invalid structure",
ExitCode.GENERAL,
"Retry later or contact the publisher",
);
}
return index;
}
/**
* Strict shape check for entry.object (defense against a hostile/corrupted index
* anything not matching falls back to the legacy fixed key, never into the URL path).
*/
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
/** Resolve which file to download for a skill: content-addressed object, else legacy fixed key */
export function resolveAssetFileName(entry?: SkillIndexEntry): string {
const object = entry?.object;
return object && OBJECT_FILE_RE.test(object) ? object : "skill.tar.br";
}
/** Download the tar.br archive for a single skill (one skill = one GET) */
export async function downloadSkillAsset(name: string, entry?: SkillIndexEntry): Promise<Buffer> {
const url = `${getSkillRegistryBaseUrl()}/${name}/${resolveAssetFileName(entry)}`;
let res: Response;
try {
res = await fetch(url, { signal: AbortSignal.timeout(ASSET_TIMEOUT_MS) });
} catch (err) {
throw new BailianError(
`Failed to download skill ${name}: ${url}`,
ExitCode.NETWORK,
"Network error, retryable",
{
cause: err,
},
);
}
if (!res.ok) {
throw new BailianError(
`Failed to download skill ${name}: HTTP ${res.status}`,
ExitCode.NETWORK,
res.status === 404
? "index.json and skill object are temporarily inconsistent (publishing in progress), retry later"
: "Remote error, retry later",
);
}
return Buffer.from(await res.arrayBuffer());
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Sanitize a skill name into a safe directory name (semantics aligned with vercel-labs/skills sanitizeName):
* skill names come from the remote index (untrusted input) and are interpolated into file paths, so they
* must be disinfected first path separators/drive letters/whitespace/Windows-illegal chars are collapsed
* to hyphens, `..` is destroyed, leading/trailing `.-` are stripped.
*
* `bl skill` uses this as an "equivalence check": if the sanitized name differs from the original,
* installation is rejected outright (the publisher already has an isomorphic allowlist; this is client-side defense-in-depth).
*/
export function sanitizeSkillName(name: string): string {
const sanitized = name
.replace(/[\\/:*?"<>|\s]+/g, "-")
.replace(/\.\.+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "");
return sanitized || "unnamed-skill";
}
/** Whether the skill name is already a safe directory name (unchanged after sanitization) */
export function isSafeSkillName(name: string): boolean {
return name.length > 0 && sanitizeSkillName(name) === name;
}
+86
View File
@@ -0,0 +1,86 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { getSkillsDir } from "./lock.ts";
import type { SkillLockFile, SkillStatusRow, SkillsIndex } from "./types.ts";
/**
* Three-way reconciliation for list: remote index (live) × skill-lock.json (installation facts) × disk (ground truth).
*/
/** Scan skill directories under canonical (skipping hidden entries, tmp/backup remnants, and plain files) */
export function listSkillDirsOnDisk(): string[] {
const dir = getSkillsDir();
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((entry) => {
if (entry.startsWith(".")) return false;
if (entry.includes(".tmp-") || entry.includes(".old-")) return false;
try {
return statSync(join(dir, entry)).isDirectory();
} catch {
return false;
}
});
}
export function computeSkillStatuses(
index: SkillsIndex,
lock: SkillLockFile,
diskNames: string[],
): SkillStatusRow[] {
const disk = new Set(diskNames);
const seen = new Set<string>();
const rows: SkillStatusRow[] = [];
// Skills present in remote: derive status from lock/disk
for (const [name, entry] of Object.entries(index.skills)) {
seen.add(name);
const locked = lock.skills[name];
if (locked) {
const status = !disk.has(name)
? "missing" // was installed but dir was deleted; reinstall can fix
: locked.contentHash !== entry.contentHash
? "outdated"
: "installed";
rows.push({
name,
status,
publishedAt: entry.publishedAt,
description: entry.description,
});
} else if (disk.has(name)) {
// Dir exists but no install record (manually placed, or synced by postinstall/advisor or other channels)
rows.push({
name,
status: "untracked",
publishedAt: entry.publishedAt,
description: entry.description,
});
} else {
rows.push({
name,
status: "not-installed",
publishedAt: entry.publishedAt,
description: entry.description,
});
}
}
// In lock but delisted from remote: still usable locally (installed) or dir also gone (missing)
for (const [name, locked] of Object.entries(lock.skills)) {
if (seen.has(name)) continue;
seen.add(name);
rows.push({
name,
status: disk.has(name) ? "installed" : "missing",
publishedAt: locked.publishedAt,
description: locked.description,
});
}
// On disk but in neither lock nor remote → untracked
for (const name of diskNames) {
if (!seen.has(name)) rows.push({ name, status: "untracked" });
}
return rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}

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