mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2389681ad6 | |||
| 946b7029c6 | |||
| b9ecd5c43b | |||
| 978f332fea | |||
| 4502424200 | |||
| 03839766bc | |||
| 1f8b9ace7e | |||
| 6338df36be | |||
| cb6740965f | |||
| 2dffee5b7a | |||
| 01ec13aad8 | |||
| b68ff45fb9 | |||
| 4990b27436 | |||
| 262681484b | |||
| 8488b251f7 | |||
| b1908fa879 | |||
| d64ba09bef | |||
| 8cdd54cf7a | |||
| 121fa1317f | |||
| 564e21d9f1 | |||
| 081d09863b | |||
| 17b13de162 | |||
| ca98d8a25d | |||
| 1e1f5306b3 | |||
| 13158856e8 | |||
| cf2592c07d | |||
| 3766b6d7ca | |||
| 1962758b0c | |||
| 026e250cd3 | |||
| 6d61afc1d5 | |||
| 7a870ec417 | |||
| 1c38c381e5 | |||
| 658763af2c | |||
| da2ddb7a55 | |||
| be3033baf9 | |||
| 8ad3e7b947 | |||
| 525412f566 | |||
| 75b056ba64 | |||
| f5a36b1787 | |||
| 9fb388b75d | |||
| 45d468838f | |||
| ed81178ad7 | |||
| 7e23ba00fb | |||
| 72955d66a7 | |||
| 389c932390 | |||
| 6870dc50a6 | |||
| 54b95ed122 | |||
| 5e2833569a | |||
| 434aac5b08 | |||
| e46053b93e | |||
| 30fe8182f4 | |||
| 65c0fe9604 | |||
| 2c53b0692b | |||
| adc89f635d | |||
| fb0c4b81be | |||
| 952f2277a4 | |||
| 871c667e97 | |||
| 4c494207d6 | |||
| af3286dd00 | |||
| 6465c4a78a | |||
| 467756b319 | |||
| 7250de9228 | |||
| 51ed69596e | |||
| 67b7fa30a7 | |||
| bd17c27023 | |||
| 87c37994f2 | |||
| ebbd173b79 | |||
| 6bdc16597b | |||
| e736bab9c1 | |||
| 8dd786287f | |||
| d30fb2ae68 | |||
| a1a448c5d2 | |||
| 7b949d3d3c | |||
| 168e2b5ccb | |||
| 9fbd2e4ec6 | |||
| 4bd84e934c | |||
| 08bdc3be97 | |||
| 66a797203c | |||
| 90a44d7140 | |||
| e1caee99f2 | |||
| 9ab5de8c2e | |||
| d08edf0cd8 |
@@ -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 }}"
|
||||
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
# Dependencies & build output
|
||||
node_modules
|
||||
dist
|
||||
dist-bin
|
||||
dist-ssr
|
||||
tools/generated
|
||||
.node-version
|
||||
@@ -36,7 +37,9 @@ tools/generated
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.cursor/
|
||||
.qoder/
|
||||
.qwen/
|
||||
.qoder
|
||||
.playwright-mcp/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -46,3 +49,6 @@ packages/cli/scene/**/outputs/
|
||||
|
||||
# Environment variables (sensitive data)
|
||||
.env
|
||||
|
||||
# Local scratch / plan drafts (never commit)
|
||||
.scratch/
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| ----------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `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 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
|
||||
|
||||
@@ -6,6 +6,55 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.14.1] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Focused Bailian Skills** — `npx skills add modelstudioai/cli --all -g` now installs dedicated skills for media generation, fine-tuning, Managed Agent, and shared execution rules, improving task routing while reducing irrelevant context.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default image model upgraded to Qwen-Image 3.0** — image generation, image editing, pipelines, the config UI, and related documentation now default to `qwen-image-3.0` for API Key users.
|
||||
- **Broader coding-agent compatibility** — Skill installation and updates now detect more coding agents, preserve existing installation links, and automatically backfill skills into newly detected agents.
|
||||
|
||||
## [1.14.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Standalone installation without Node.js** — binary packages are available for macOS on Apple Silicon and Intel, Linux x64, and Windows x64; npm installation remains supported.
|
||||
- **Exact-version updates** — binary and npm installations can use `bl update --to <version>` to update or switch to a specified version.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Binary self-updates** — binary installations now check and download updates through a dedicated release channel. `bl update` no longer replaces the running executable, and the next invocation automatically uses the new version.
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default text model upgraded to Qwen3.8-Max** — `bl text chat`, pipelines, API key validation, the config UI, and Managed Agent init templates now default to `qwen3.8-max`; Token Plan also moves from the preview model to the stable release.
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config ui` Skills / MCP / Agents / Assets inventory** — browse installed skills, MCP servers, coding agents, and generated assets in the local Web UI with click-to-open detail drawers:
|
||||
- Skills: render `SKILL.md` as Markdown (GFM tables supported), show local vs remote origin badges, and install a skill by uploading a `.zip` archive into any supported agent's skills root.
|
||||
- MCP: view and edit JSON configuration with secret masking and mask-preserving writes; create, update, and delete MCP entries across Claude Code, Qwen Code, OpenCode, Cursor, Windsurf, Gemini, Qoder Work, OpenClaw, and Claude Desktop.
|
||||
- Agents: quick-launch coding agents directly from the UI (gated on the CLI binary being on PATH).
|
||||
- Assets: categorized, time-sorted browser with preview, open-locally, and delete.
|
||||
- **Model catalog suggestion chips** — per-category model names surfaced as click-to-fill chips under each `default_*_model` field in the config UI.
|
||||
- **Profiles tile grid** — profiles displayed as a tile grid with an add-tile and a design-consistent new-profile modal.
|
||||
|
||||
### Changed
|
||||
|
||||
- Config UI layout: collapsible grouped sidebar with icons and persistent state, responsive breakpoint, wider main area, sticky view headers, and right-side drawers for editing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Symlinked skill directories are now correctly identified as an installed source.
|
||||
- Config file detection now supports environment-variable-based paths and legacy configuration schemes.
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,55 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.14.1] - 2026-08-05
|
||||
|
||||
### 新增
|
||||
|
||||
- **百炼 Skill 按领域拆分** —— 通过 `npx skills add modelstudioai/cli --all -g` 可统一安装图片与视频生成、模型微调、Managed Agent 和共享执行协议等专用 Skill,提升任务路由准确性并减少无关上下文。
|
||||
|
||||
### 变更
|
||||
|
||||
- **默认图片模型升级至 Qwen-Image 3.0** —— 普通 API Key 用户的图片生成、图片编辑、Pipeline、配置 UI 和相关文档现在默认使用 `qwen-image-3.0`。
|
||||
- **扩展 Coding Agent 兼容范围** —— Skill 安装与更新现在能够识别更多 Coding Agent,保留已有安装链接,并自动将 Skill 补充到新识别的 Agent。
|
||||
|
||||
## [1.14.0] - 2026-08-04
|
||||
|
||||
### 新增
|
||||
|
||||
- **免 Node.js 的二进制安装** — 支持 macOS Apple Silicon / Intel、Linux x64 和 Windows x64;npm 安装方式继续保留。
|
||||
- **指定版本更新** — 二进制和 npm 安装均可通过 `bl update --to <version>` 更新或切换到指定版本。
|
||||
|
||||
### 变更
|
||||
|
||||
- **二进制自更新** — 二进制安装现在通过独立的发布通道检查和下载更新;执行 `bl update` 时不会覆盖正在运行的程序,下次运行自动使用新版本。
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### 变更
|
||||
|
||||
- **默认文本模型升级至 Qwen3.8-Max** — `bl text chat`、Pipeline、API Key 登录校验、配置 UI 和 Managed Agent 初始化模板现在默认使用 `qwen3.8-max`;Token Plan 也由预览版切换至正式版。
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config ui` 技能 / MCP / 代理 / 资产清单** — 在本地 Web UI 中浏览已安装的技能、MCP 服务器、编码代理和生成的资产,点击打开右侧详情抽屉:
|
||||
- 技能:将 `SKILL.md` 渲染为 Markdown(支持 GFM 表格),展示本地/远程来源徽章,支持上传 `.zip` 压缩包将技能安装到任意受支持代理的技能目录。
|
||||
- MCP:查看和编辑 JSON 配置,支持密钥掩码与掩码保真写回;支持在 Claude Code、Qwen Code、OpenCode、Cursor、Windsurf、Gemini、Qoder Work、OpenClaw 和 Claude Desktop 中创建、更新、删除 MCP 条目。
|
||||
- 代理:从 UI 一键启动编码代理(需对应 CLI 二进制在 PATH 中)。
|
||||
- 资产:按类别分组、按时间排序的浏览器,支持预览、本地打开和删除。
|
||||
- **模型目录建议芯片** — 在配置 UI 的每个 `default_*_model` 字段下方展示按类别分组的模型名称,点击即可填入。
|
||||
- **Profile 磁贴网格** — 配置文件以磁贴网格展示,新增添加磁贴和设计一致的新建 Profile 弹窗。
|
||||
|
||||
### 变更
|
||||
|
||||
- 配置 UI 布局:可折叠分组侧边栏(带图标和持久化状态)、响应式断点、更宽的主区域、吸顶视图标题、右侧抽屉式编辑。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复软链接技能目录未被正确识别为已安装来源的问题。
|
||||
- 配置文件检测现支持基于环境变量的路径和旧版配置方案。
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
+58
-76
@@ -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,8 +13,9 @@
|
||||
|
||||
---
|
||||
|
||||
_Chat with Qwen, generate images & videos, understand images, call agents,_
|
||||
_manage memory, search the web — all from your terminal._
|
||||
_Chat with Qwen, generate and edit images and videos, understand images, synthesize_
|
||||
_and recognize speech, call apps, manage memory, retrieve knowledge, search the web —_
|
||||
_every AI capability, one command away._
|
||||
|
||||
_Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
@@ -22,28 +23,16 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
## Features
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
|
||||
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
|
||||
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
|
||||
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
|
||||
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
|
||||
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
|
||||
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
## Showcase 1: A Cinematic Short Film from One Sentence
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -56,26 +45,45 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Generate a roughly 2-minute video in Japanese cinematic style — a sweet, innocent first-love story about a high-school girl. The plot should be heart-fluttering enough to make viewers want to fall in love. Aspect ratio: 16:9."_
|
||||
>
|
||||
> _(Original: "帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2分钟左右的视频,尺寸是16:9")_
|
||||
|
||||
### How it works
|
||||
## Showcase 2: A Short-Film Director Managed Agent from One Sentence
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="Click to play the demo video" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
<p align="center"><i>👆 Click the cover to play the full demo</i></p>
|
||||
|
||||
One sentence builds a reusable cloud-side short-film director for storyboarding, storyboard image generation, and video creation:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — understands the requirement and generates the agent configuration
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — validates the configuration, previews the changes, and completes the deployment
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** — runs the director role along with its skills and tools in the cloud
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Build me a Managed Agent app that can produce short films — a director expert that generates videos and can also design the matching storyboards."_
|
||||
|
||||
## Installation
|
||||
|
||||
**Agent install (recommended)**
|
||||
|
||||
Send the following to your Agent — it will detect your environment, then install and verify the CLI for you:
|
||||
|
||||
```text
|
||||
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
|
||||
```
|
||||
|
||||
**Manual install (npm)**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
@@ -85,91 +93,29 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Authenticate, recommended
|
||||
bl auth login --console
|
||||
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
|
||||
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
# Multimodal chat (text + image + audio + video)
|
||||
bl omni --message "Describe this image" --image ./photo.jpg
|
||||
|
||||
# Generate an image
|
||||
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
|
||||
|
||||
# Generate a video from local image
|
||||
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
|
||||
|
||||
# Model recommendation — find the best model for your use case
|
||||
bl advisor recommend --message "I need a visual-understanding chatbot"
|
||||
|
||||
# Compare specific models
|
||||
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
|
||||
# Browser login (required for console capability commands)
|
||||
bl auth login --console
|
||||
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| Scenario | What to say to your Agent |
|
||||
| ------------------------ | --------------------------------------------------------------------------------- |
|
||||
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
|
||||
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
|
||||
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
|
||||
| Model selection | "Recommend a model for image understanding and customer support." |
|
||||
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## Authentication
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# Option 2: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Option 3: Per-command flag
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -177,26 +123,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
|
||||
### Alibaba Cloud OpenAPI AK/SK
|
||||
|
||||
Required for the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
|
||||
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
|
||||
|
||||
```bash
|
||||
# Option 1: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# Option 2: Environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -205,17 +145,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# View current config
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
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
|
||||
# List all config profiles
|
||||
bl config list
|
||||
|
||||
# Self-update to latest version
|
||||
bl update
|
||||
# Switch config profile
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
Upgrades the CLI to the latest version and refreshes the installed Agent Skills. Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usage help, troubleshooting, bug reports, and tips from other users.
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="Aliyun Model Studio CLI DingTalk user group" width="240" />
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
@@ -227,11 +181,3 @@ Config file location: `~/.bailian/config.json`
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
+72
-125
@@ -22,28 +22,16 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
## 功能特性
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
## 示例 1:一句话生成一部电影短片
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -53,27 +41,48 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
<p align="center"><i>👆 点击封面播放完整 2 分钟演示</i></p>
|
||||
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _"帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9"_
|
||||
> _“帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9。”_
|
||||
|
||||
### 工作流程
|
||||
## 示例 2:一句话构建短片导演 Managed Agent
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="点击播放演示视频" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
<p align="center"><i>👆 点击封面播放完整演示</i></p>
|
||||
|
||||
一句话构建一个可复用的云端短片导演,用于分镜设计、分镜图生成和视频创作:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— 理解需求并生成 Agent 配置
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 校验配置、预览变更并完成部署
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** —— 在云端运行导演角色及其 Skill 和工具
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _“帮我构建一个 managedagent 应用,能够实现短片拍摄,导演专家生成视频,然后也能进行设计对应的分镜图。”_
|
||||
|
||||
## 安装
|
||||
|
||||
**Agent 安装(推荐)**
|
||||
|
||||
把下面这句话发给你的 Agent,它会自行判断环境并完成安装与校验:
|
||||
|
||||
```text
|
||||
请阅读:https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
|
||||
```
|
||||
|
||||
**手动安装(npm)**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
@@ -83,91 +92,29 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
# 多模态对话(文本 + 图片 + 音频 + 视频)
|
||||
bl omni --message "描述这张图片" --image ./photo.jpg
|
||||
|
||||
# 生成图片
|
||||
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
|
||||
|
||||
# 图生视频(本地文件自动上传)
|
||||
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
|
||||
|
||||
# 模型推荐 — 根据场景推荐最适合的模型
|
||||
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
|
||||
|
||||
# 对比特定模型
|
||||
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
|
||||
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
bl auth login --console
|
||||
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| 场景 | 可以这样对 Agent 说 |
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## 认证方式
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# 方式二:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 方式三:命令行参数
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
Token Plan 的 API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -175,26 +122,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 OpenAPI AK/SK(仅 Token Plan)
|
||||
### 阿里云 OpenAPI AK/SK
|
||||
|
||||
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
```bash
|
||||
# 方式一:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# 方式二:环境变量
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## 配置
|
||||
@@ -203,17 +144,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# 查看当前配置
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
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
|
||||
# 查看全部配置档
|
||||
bl config list
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
# 切换配置档
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
升级 CLI 至最新版本,并同步更新已安装的 Agent Skills。每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
欢迎扫码加入阿里云百炼 CLI 钉钉用户交流群,获取使用答疑、问题排查、Bug 反馈和使用经验交流支持。
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="阿里云百炼 CLI 钉钉用户交流群" width="240" />
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
@@ -225,11 +180,3 @@ bl update
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
@@ -25,7 +25,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
当前 command 鉴权域(`AuthRequirement`):
|
||||
|
||||
- `apiKey` — DashScope / OpenAI-compatible 模型域,用 API key 与 model base URL
|
||||
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent/workspace
|
||||
- `console` — Bailian Console Gateway,用 console access token + region/site/switchAgent;`workspace_id` 是独立的 Settings 作用域,不属于 credential
|
||||
- `openapi` — 阿里云 OpenAPI 签名域,用 AccessKey ID/Secret 调用 Token Plan 等 OpenAPI
|
||||
- `none` — 本地命令、登录/配置类命令、无需 credential 的命令
|
||||
|
||||
@@ -35,7 +35,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
- `bl auth login --api-key ...` 只更新 `api_key` / `base_url`
|
||||
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
|
||||
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
|
||||
- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证
|
||||
- `bl auth logout --console` 只清 `access_token`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
|
||||
- `bl auth logout` 清 `api_key` + `base_url` + `access_token` + `access_key_*`
|
||||
@@ -78,6 +78,9 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
- 如新增鉴权域,扩展 `AuthRequirement`
|
||||
- 更新 `credentialFlagDefs()` 暴露该域可见的 flag
|
||||
- 必要时新增 `*_AUTH_FLAGS`
|
||||
- `workspace_id` 是作用域字段而非 credential,不要把它放进 `ConsoleCredential`;读取方式按命令 `auth` 域区分:
|
||||
- `auth: "console"` 命令通过 `CONSOLE_AUTH_FLAGS` 自动获得 `--workspace-id`,由 `buildSettings()` 解析到 `settings.workspaceId`,命令统一从 `settings.workspaceId` 读取
|
||||
- `auth: "apiKey"`/`"openapi"`/`"none"` 命令如需 `--workspace-id`,必须自声明 flag;因它不会进入 credential/global flags,命令从 `ctx.flags.workspaceId` 读取(可回退到 `settings.workspaceId`)
|
||||
- [ ] `packages/core/src/auth/types.ts`:
|
||||
- 新增 credential 类型 / source / scope 字段
|
||||
- [ ] `packages/core/src/auth/resolver.ts`:
|
||||
@@ -121,7 +124,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. 测试
|
||||
|
||||
@@ -131,6 +134,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
## 完成后自查
|
||||
|
||||
本仓库同时存在 `bl`(packages/cli) 与 `kscli`(packages/kscli) 两个入口,二者共享 core/runtime 鉴权链路,但暴露的命令不同。如果改动会影响两个入口共用的命令或错误提示,再分别验证它们各自实际暴露的路径;不要假设 `kscli` 也有 `bl auth *` 命令。
|
||||
|
||||
```sh
|
||||
# 各种凭证组合
|
||||
unset DASHSCOPE_API_KEY ALIBABA_CLOUD_ACCESS_KEY_ID ALIBABA_CLOUD_ACCESS_KEY_SECRET
|
||||
@@ -150,9 +155,11 @@ Console 登录/网关相关改动:
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli exec tsx src/main.ts auth login --console
|
||||
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json
|
||||
pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json --workspace-id ws-xxx
|
||||
```
|
||||
|
||||
注意:`usage stats --dry-run` 仍会先校验 workspace,必须传入 `--workspace-id`(或 `BAILIAN_WORKSPACE_ID` / config `workspace_id`)。
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- ✗ 加了新 token 来源但忘了改 resolver 优先级,实际不生效
|
||||
|
||||
@@ -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 注明用户感知的差异
|
||||
|
||||
@@ -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);不破坏已有集成用例顺序
|
||||
|
||||
@@ -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 的子组是反模式,新增时优先拍平为两级
|
||||
|
||||
@@ -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. 测试层
|
||||
|
||||
|
||||
@@ -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` 等正式对外发布时再补。
|
||||
|
||||
验证:
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@
|
||||
- `config list` 标识所有 Profile 与当前激活项。
|
||||
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
|
||||
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
|
||||
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
|
||||
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
|
||||
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
|
||||
- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。
|
||||
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
|
||||
|
||||
## 6. 最小测试矩阵
|
||||
@@ -62,7 +64,8 @@
|
||||
`--config default` 成功后切回 `default`。
|
||||
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
|
||||
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
|
||||
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
|
||||
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
|
||||
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
|
||||
|
||||
## 7. 完成检查
|
||||
|
||||
|
||||
@@ -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
@@ -1,27 +1,53 @@
|
||||
# 发布(npm publish)
|
||||
# 发布(npm + GitHub Release 二进制)
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 准备发布 channel(beta/mcp/plugin 等)或正式版到 npm
|
||||
- 准备打 git tag
|
||||
- 准备发布 channel(mcp/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 ← 唯一发版入口
|
||||
├─ npm(pnpm publish)
|
||||
└─ binary(lib/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=channel,channel 填 **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 workflow,package 选 `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 workflow,mode 选 `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 workflow,package 选目标包集合,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. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,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 版本**:24(npm 11.5+ 才支持 OIDC token 交换)
|
||||
- **Bun**:`oven-sh/setup-bun`,版本钉死在 workflow 中
|
||||
- **Actions 版本**:checkout/setup-node/pnpm-action 均为 v6(Node 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 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
| channel job 缺少 `contents: write` | `gh release create` 失败 |
|
||||
| stable 未先推 tag 就建 Release | `--verify-tag` 失败 |
|
||||
|
||||
@@ -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-cli(hub)
|
||||
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. 文案与落款一致性
|
||||
|
||||
- [ ] 领域 skill(gen / 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))
|
||||
@@ -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 Key;DashScope / 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 token;Bailian 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
|
||||
@@ -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. 渠道追踪参数
|
||||
|
||||
@@ -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:"
|
||||
|
||||
+72
-126
@@ -13,8 +13,9 @@
|
||||
|
||||
---
|
||||
|
||||
_Chat with Qwen, generate images & videos, understand images, call agents,_
|
||||
_manage memory, search the web — all from your terminal._
|
||||
_Chat with Qwen, generate and edit images and videos, understand images, synthesize_
|
||||
_and recognize speech, call apps, manage memory, retrieve knowledge, search the web —_
|
||||
_every AI capability, one command away._
|
||||
|
||||
_Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
@@ -22,28 +23,16 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
## Features
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
- **Model generation** — Full-modality generation across text, image, video, and speech, with editing and reference-based generation
|
||||
- **Asset understanding** — Parse and ask questions about images, documents, audio, and long videos
|
||||
- **App orchestration** — Call Managed Agents, agents, and workflows published on Aliyun Model Studio, wired to knowledge bases, memory, web search, and MCP tools
|
||||
- **Training & deployment** — Validate and upload datasets, fine-tune models, deploy dedicated models as endpoints
|
||||
- **Account operations** — Login, UI-based configuration, model marketplace, usage and quota, rate-limit increases, team seat management
|
||||
- **Plan onboarding** — Connect subscription plans such as Token Plan to the CLI and common coding agents in one step
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
> **Note:** App orchestration, training & deployment, account operations, and plan onboarding are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
- **Knowledge base & memory** — Multimodal RAG retrieval and cross-session memory for personalized, coherent dialogue
|
||||
- **App calls** — Invoke agents and workflows already published on Aliyun Model Studio
|
||||
- **MCP integration** — Orchestrate Bailian MCP servers: list services, inspect tools, and invoke any tool directly from the terminal
|
||||
- **Web search** — Real-time internet retrieval for up-to-date, accurate answers
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
## Showcase 1: A Cinematic Short Film from One Sentence
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -56,26 +45,45 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
A complete **2-minute, 16:9 cinematic short film** — produced end-to-end from a single natural-language sentence, with **zero manual editing**. This showcase demonstrates how an AI Agent can compose a multi-step creative pipeline by orchestrating three primitives:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — the agentic coding model that interprets the user's intent and drives the workflow
|
||||
- **[Aliyun Model Studio CLI](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — invokes **HappyHorse 1.1**, Aliyun Model Studio's text-/image-/reference-to-video generation model
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** — handles scene decomposition, storyboarding, shot continuity, and final stitching
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Generate a roughly 2-minute video in Japanese cinematic style — a sweet, innocent first-love story about a high-school girl. The plot should be heart-fluttering enough to make viewers want to fall in love. Aspect ratio: 16:9."_
|
||||
>
|
||||
> _(Original: "帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2分钟左右的视频,尺寸是16:9")_
|
||||
|
||||
### How it works
|
||||
## Showcase 2: A Short-Film Director Managed Agent from One Sentence
|
||||
|
||||
1. **Qwen Code** parses the request, plans the narrative beats, and decides which tools to call.
|
||||
2. The **spark-video Skill** breaks the story into shots, writes per-shot prompts, and enforces visual continuity (characters, lighting, palette, lens language).
|
||||
3. **`bl video generate`** dispatches each shot to **HappyHorse 1.1** in parallel.
|
||||
4. The skill stitches all clips back together into a single 16:9 / ~2-min deliverable.
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="Click to play the demo video" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
No timeline scrubbing. No frame-by-frame editing. Just one sentence → one video.
|
||||
<p align="center"><i>👆 Click the cover to play the full demo</i></p>
|
||||
|
||||
One sentence builds a reusable cloud-side short-film director for storyboarding, storyboard image generation, and video creation:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** — understands the requirement and generates the agent configuration
|
||||
- **[Aliyun Model Studio CLI](https://github.com/modelstudioai/cli/)** — validates the configuration, previews the changes, and completes the deployment
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** — runs the director role along with its skills and tools in the cloud
|
||||
|
||||
### The single prompt
|
||||
|
||||
> _"Build me a Managed Agent app that can produce short films — a director expert that generates videos and can also design the matching storyboards."_
|
||||
|
||||
## Installation
|
||||
|
||||
**Agent install (recommended)**
|
||||
|
||||
Send the following to your Agent — it will detect your environment, then install and verify the CLI for you:
|
||||
|
||||
```text
|
||||
Please read https://bailian.aliyun.com/cli/install.md and install the Aliyun Model Studio CLI for me
|
||||
```
|
||||
|
||||
**Manual install (npm)**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
@@ -85,91 +93,29 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Authenticate, recommended
|
||||
bl auth login --console
|
||||
Once installed, just describe your task to your AI Agent — no need to assemble commands by hand.
|
||||
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
# Multimodal chat (text + image + audio + video)
|
||||
bl omni --message "Describe this image" --image ./photo.jpg
|
||||
|
||||
# Generate an image
|
||||
bl image generate --prompt "A cat in a spacesuit" --out-dir ./images/
|
||||
|
||||
# Generate a video from local image
|
||||
bl video generate --image ./cat.png --prompt "Make the cat move" --download cat.mp4
|
||||
|
||||
# Model recommendation — find the best model for your use case
|
||||
bl advisor recommend --message "I need a visual-understanding chatbot"
|
||||
|
||||
# Compare specific models
|
||||
bl advisor recommend --message "qwen-max vs deepseek-v3 for code generation"
|
||||
|
||||
# Browser login (required for console capability commands)
|
||||
bl auth login --console
|
||||
|
||||
# Fine-tune & deploy — a one-shot train-to-serve workflow
|
||||
bl dataset upload --file ./train.jsonl # Upload a .jsonl dataset (validated first)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # Local paths auto-upload
|
||||
bl finetune watch --job-id ft-xxx --output json # Non-blocking probe (running/succeeded return 0; failed/canceled report an error)
|
||||
bl finetune capability --model qwen3-8b # Which training types a model supports
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # Deploy the trained model as an endpoint
|
||||
|
||||
# Browse models / apps / free-tier quota / usage statistics / workspaces
|
||||
bl model list # Browse model families and pricing
|
||||
bl app list
|
||||
bl usage summary # Unified view: free-tier quota + recent usage overview
|
||||
bl usage free # Free-tier quota across models (add --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # Model usage statistics (add --model for per-model)
|
||||
bl workspace list # List all workspaces
|
||||
|
||||
# Rate limit management (list / check / request / history)
|
||||
bl quota list # View RPM/TPM limits (add --model to filter)
|
||||
bl quota check # Current usage vs rate limits (add --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| Scenario | What to say to your Agent |
|
||||
| ------------------------ | --------------------------------------------------------------------------------- |
|
||||
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
|
||||
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
|
||||
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
|
||||
| Model selection | "Recommend a model for image understanding and customer support." |
|
||||
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
|
||||
|
||||
> More examples and scenarios: [Aliyun Model Studio CLI Site](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## Authentication
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
Required for most commands. Get your key from the [DashScope Console](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key).
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variable
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# Option 2: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Option 3: Per-command flag
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
Get or copy your Token Plan API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -177,26 +123,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (model list, app list, MCP list, workspace, usage queries, rate-limit increases, direct console calls). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### Alibaba Cloud OpenAPI AK/SK (Token Plan only)
|
||||
### Alibaba Cloud OpenAPI AK/SK
|
||||
|
||||
Required for the `token-plan` command group. Get your AccessKey from [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
Token Plan seat and member management requires an Alibaba Cloud AccessKey. Get yours from the [RAM Console](https://ram.console.aliyun.com/manage/ak).
|
||||
|
||||
> Recommended: create a RAM sub-account with minimum privileges instead of using the root account's AK/SK.
|
||||
|
||||
```bash
|
||||
# Option 1: Login command (persisted to ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# Option 2: Environment variables
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -205,17 +145,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# View current config
|
||||
bl config show
|
||||
|
||||
# Set defaults
|
||||
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
|
||||
# List all config profiles
|
||||
bl config list
|
||||
|
||||
# Self-update to latest version
|
||||
bl update
|
||||
# Switch config profile
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
Upgrades the CLI to the latest version and refreshes the installed Agent Skills. Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
Scan the QR code to join the Aliyun Model Studio CLI DingTalk user group for usage help, troubleshooting, bug reports, and tips from other users.
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="Aliyun Model Studio CLI DingTalk user group" width="240" />
|
||||
|
||||
## Links
|
||||
|
||||
| Resource | URL |
|
||||
@@ -227,11 +181,3 @@ Config file location: `~/.bailian/config.json`
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
Release notes for every version live in [CHANGELOG.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, feature requests, and PRs are welcome. See [CONTRIBUTING.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.md) for developer setup, repo layout, and the workflow for adding or changing commands.
|
||||
|
||||
+72
-125
@@ -22,28 +22,16 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
## 功能特性
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
- **模型生成** — 文本、图像、视频、语音全模态生成,支持编辑与参考生成
|
||||
- **素材理解** — 图像、文档、音频、长视频的解析与问答
|
||||
- **应用编排** — 调用百炼已发布的 Managed Agent、智能体和工作流,接入知识库、记忆库、联网搜索与 MCP 工具
|
||||
- **模型训推** — 数据集校验上传、模型精调、专属模型部署上线
|
||||
- **账号运维** — 授权登录、界面化配置、模型市场、用量与额度、限流提额、团队席位管理
|
||||
- **套餐接入** — 支持 Token Plan 等订阅计划一键接到 CLI 和常见 Coding Agent
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
> **注意:** 应用编排、模型训推、账号运维和套餐接入目前仅支持中国站(aliyun.com)账号,暂不支持国际站 / 全球站账号。
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
- **知识库与记忆库** — 多模态 RAG 检索 + 跨会话记忆,提供个性化连贯对话体验
|
||||
- **应用调用** — 调用已发布在阿里云百炼平台上的智能体与工作流应用
|
||||
- **MCP 集成** — 统一调度百炼 MCP 服务:列出服务、查看工具、直接在终端调用任意工具
|
||||
- **联网搜索** — 实时互联网信息检索,提升回答准确性及时效性
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
## 示例 1:一句话生成一部电影短片
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
|
||||
@@ -53,27 +41,48 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
<p align="center"><i>👆 点击封面播放完整 2 分钟演示</i></p>
|
||||
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
一部完整的 **2 分钟、16:9 电影感短片** —— 由一句自然语言端到端生成,**全程零手动剪辑**。这个示例展示了 AI Agent 如何把三个基础能力编排成一条多步创作流水线:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— Agentic coding 模型,解析用户意图、驱动整个工作流
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 调用 **HappyHorse 1.1**,百炼的文生/图生/参考生视频模型
|
||||
- **[spark-video Skill](https://github.com/JohnKeating1997/spark-video)** —— 负责场景拆分、分镜设计、镜头连贯性和最终拼接
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _"帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9"_
|
||||
> _“帮我生成一段日系影视风格,高中女生的青涩初恋故事,剧情高甜,让人看了想谈恋爱,2 分钟左右的视频,尺寸是 16:9。”_
|
||||
|
||||
### 工作流程
|
||||
## 示例 2:一句话构建短片导演 Managed Agent
|
||||
|
||||
1. **Qwen Code** 解析需求、规划叙事节奏,决定要调用哪些工具。
|
||||
2. **spark-video Skill** 把故事拆成镜头、为每个镜头写提示词,并保证视觉连贯性(角色、光线、色调、镜头语言)。
|
||||
3. **`bl video generate`** 把每个镜头并行下发给 **HappyHorse 1.1**。
|
||||
4. Skill 把所有片段拼成最终的 16:9 / 约 2 分钟成片。
|
||||
<p align="center">
|
||||
<a href="https://cloud.video.taobao.com/vod/2v0GYLbJSQb2saj4iopTJDW3iRIHsintYlK-wTKbhqE.mp4">
|
||||
<img src="https://img.alicdn.com/imgextra/i4/6000000001674/O1CN01xhzixhxltbH3LxWu_!!6000000001674-0-tbvideo.jpg" alt="点击播放演示视频" width="720" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
没有时间线拖拽,没有逐帧剪辑。一句话 → 一部短片。
|
||||
<p align="center"><i>👆 点击封面播放完整演示</i></p>
|
||||
|
||||
一句话构建一个可复用的云端短片导演,用于分镜设计、分镜图生成和视频创作:
|
||||
|
||||
- **[Qwen Code](https://github.com/QwenLM/qwen-code)** —— 理解需求并生成 Agent 配置
|
||||
- **[阿里云百炼 CLI](https://github.com/modelstudioai/cli/)** —— 校验配置、预览变更并完成部署
|
||||
- **[Managed Agent](https://bailian.console.aliyun.com/cn-beijing/?tab=managed-agents#/managed-agents/quick-start)** —— 在云端运行导演角色及其 Skill 和工具
|
||||
|
||||
### 唯一的提示词
|
||||
|
||||
> _“帮我构建一个 managedagent 应用,能够实现短片拍摄,导演专家生成视频,然后也能进行设计对应的分镜图。”_
|
||||
|
||||
## 安装
|
||||
|
||||
**Agent 安装(推荐)**
|
||||
|
||||
把下面这句话发给你的 Agent,它会自行判断环境并完成安装与校验:
|
||||
|
||||
```text
|
||||
请阅读:https://bailian.aliyun.com/cli/install.md 并按照说明为我安装阿里云百炼 CLI
|
||||
```
|
||||
|
||||
**手动安装(npm)**
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
@@ -83,91 +92,29 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 认证(推荐浏览器登录)
|
||||
bl auth login --console
|
||||
安装完成后,直接在 AI Agent 中描述你的任务,无需手动拼接命令。
|
||||
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
# 多模态对话(文本 + 图片 + 音频 + 视频)
|
||||
bl omni --message "描述这张图片" --image ./photo.jpg
|
||||
|
||||
# 生成图片
|
||||
bl image generate --prompt "一只穿太空服的猫在火星上" --out-dir ./images/
|
||||
|
||||
# 图生视频(本地文件自动上传)
|
||||
bl video generate --image ./cat.png --prompt "让画面中的猫动起来" --download cat.mp4
|
||||
|
||||
# 模型推荐 — 根据场景推荐最适合的模型
|
||||
bl advisor recommend --message "我要做一个能理解图片的客服机器人"
|
||||
|
||||
# 对比特定模型
|
||||
bl advisor recommend --message "qwen-max 和 deepseek-v3 哪个更适合做代码生成"
|
||||
|
||||
# 浏览器登录(控制台能力相关命令需要)
|
||||
bl auth login --console
|
||||
|
||||
# 微调与部署 — 从训练到服务的一站式流程
|
||||
bl dataset upload --file ./train.jsonl # 上传 .jsonl 数据集(先校验)
|
||||
bl finetune text create --model qwen3-8b --datasets ./train.jsonl --training-type sft-lora # 本地路径自动上传
|
||||
bl finetune watch --job-id ft-xxx --output json # 非阻塞探测(运行中/成功返回 0;失败/取消报错)
|
||||
bl finetune capability --model qwen3-8b # 查询模型支持哪些训练方式
|
||||
bl deploy text create --model qwen3-8b --name my-svc --plan mu # 把训练好的模型部署为推理服务
|
||||
|
||||
# 浏览模型 / 应用 / 免费额度 / 用量统计 / 业务空间
|
||||
bl model list # 浏览模型系列与价格信息
|
||||
bl app list
|
||||
bl usage summary # 统一视图:免费额度 + 近期用量概览
|
||||
bl usage free # 各模型免费额度(可加 --model/--expiring/--sort)
|
||||
bl usage stats --workspace-id <id> # 模型用量统计(加 --model 查单模型)
|
||||
bl workspace list # 列出所有业务空间
|
||||
|
||||
# 限流管理与提额(list / check / request / history)
|
||||
bl quota list # 查看 RPM/TPM 限额(加 --model 过滤)
|
||||
bl quota check # 当前用量 vs 限流阈值(加 --model/--period)
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
bl token-plan assign-seats --workspace-id ws_xxx --seat-type standard --account-id acc_xxx
|
||||
bl token-plan create-key --account-id acc_xxx --workspace-id ws_xxx
|
||||
```
|
||||
| 场景 | 可以这样对 Agent 说 |
|
||||
| ---------------- | ----------------------------------------------------------------------- |
|
||||
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
|
||||
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
|
||||
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
|
||||
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
|
||||
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
|
||||
|
||||
> 更多案例与使用场景:[阿里云百炼 CLI 官方主页](https://bailian.console.aliyun.com/cli?source_channel=cli_github&)
|
||||
|
||||
## 认证方式
|
||||
|
||||
### DashScope API Key
|
||||
### API Key
|
||||
|
||||
大部分命令均需要 API Key。前往 [DashScope 控制台](https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key) 获取。
|
||||
|
||||
```bash
|
||||
# 方式一:环境变量
|
||||
export DASHSCOPE_API_KEY=sk-xxxxx
|
||||
|
||||
# 方式二:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 方式三:命令行参数
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
Token Plan 的 API Key 前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
@@ -175,26 +122,20 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(模型列表、应用列表、MCP 列表、工作空间、用量查询、限流提额、控制台直调)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
```
|
||||
|
||||
### 阿里云 OpenAPI AK/SK(仅 Token Plan)
|
||||
### 阿里云 OpenAPI AK/SK
|
||||
|
||||
`token-plan` 命令组需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
Token Plan 的席位与成员管理需要阿里云 AccessKey。前往 [RAM 控制台](https://ram.console.aliyun.com/manage/ak) 获取。
|
||||
|
||||
> 建议:创建 RAM 子账号并授予最小权限,避免使用主账号 AK/SK。
|
||||
|
||||
```bash
|
||||
# 方式一:登录命令(持久化到 ~/.bailian/config.json)
|
||||
bl auth login --open-api --access-key-id LTAI5t... --access-key-secret ...
|
||||
|
||||
# 方式二:环境变量
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5t...
|
||||
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
|
||||
export BAILIAN_WORKSPACE_ID=ws-...
|
||||
```
|
||||
|
||||
## 配置
|
||||
@@ -203,17 +144,31 @@ export BAILIAN_WORKSPACE_ID=ws-...
|
||||
# 查看当前配置
|
||||
bl config show
|
||||
|
||||
# 设置默认值
|
||||
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
|
||||
# 查看全部配置档
|
||||
bl config list
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
# 切换配置档
|
||||
bl config use --name token-plan
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
升级 CLI 至最新版本,并同步更新已安装的 Agent Skills。每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
欢迎扫码加入阿里云百炼 CLI 钉钉用户交流群,获取使用答疑、问题排查、Bug 反馈和使用经验交流支持。
|
||||
|
||||
<img src="https://img.alicdn.com/imgextra/i3/O1CN015uuhYGb6j0L12xJZ_!!6000000006304-2-tps-516-485.png" alt="阿里云百炼 CLI 钉钉用户交流群" width="240" />
|
||||
|
||||
## 相关链接
|
||||
|
||||
| 资源 | 地址 |
|
||||
@@ -225,11 +180,3 @@ bl update
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
每个版本的变更详情记录在 [CHANGELOG.zh.md](https://github.com/modelstudioai/cli/blob/main/CHANGELOG.zh.md)。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎提 Issue、Feature Request 和 PR。开发环境搭建、仓库结构、新增/修改命令的工作流请见 [CONTRIBUTING.zh.md](https://github.com/modelstudioai/cli/blob/main/CONTRIBUTING.zh.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.2",
|
||||
"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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -89,6 +89,11 @@ import {
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
skillAdd,
|
||||
skillUpdate,
|
||||
skillRemove,
|
||||
skillList,
|
||||
skillInit,
|
||||
managedAgentInit,
|
||||
managedAgentValidate,
|
||||
managedAgentPlan,
|
||||
@@ -203,6 +208,11 @@ 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,
|
||||
"skill init": skillInit,
|
||||
"managed-agent init": managedAgentInit,
|
||||
"managed-agent validate": managedAgentValidate,
|
||||
"managed-agent plan": managedAgentPlan,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.2",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GetModelsOptions,
|
||||
getModels,
|
||||
type IntentProfile,
|
||||
maybeSyncWikiData,
|
||||
type PipelineStep,
|
||||
type RecommendedModel,
|
||||
type RecommendResult,
|
||||
@@ -248,6 +249,12 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const userInput = flags.message;
|
||||
const top = 3;
|
||||
|
||||
// Keep the local wiki catalog fresh: throttled (12h) version check against
|
||||
// the remote manifest, silently replaces data when a newer version exists.
|
||||
// Never throws — a sync failure must not block recommendation.
|
||||
await maybeSyncWikiData();
|
||||
|
||||
// Default to JSON for structured output; render boxen cards only when the
|
||||
// user explicitly asked for text output.
|
||||
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
|
||||
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
|
||||
|
||||
/** Read-only auth snapshot the config UI account widget renders. bl stores no
|
||||
* user profile (name/avatar), so this exposes only which credential domains
|
||||
* resolve, the console region/site, and a masked token. */
|
||||
export interface AuthUiStatus {
|
||||
authenticated: boolean;
|
||||
methods: { apiKey: boolean; console: boolean; openapi: boolean };
|
||||
primary: "console" | "apiKey" | "openapi" | null;
|
||||
region?: string;
|
||||
site?: "domestic" | "international";
|
||||
masked?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth capability surface the config UI is allowed to use. All `authStore`
|
||||
* access is kept inside this module (commands/auth/**), which the lint boundary
|
||||
* permits; commands/config/** consumes only this opaque bridge and never
|
||||
* touches `authStore` directly.
|
||||
*/
|
||||
export interface AuthUiBridge {
|
||||
status(): AuthUiStatus;
|
||||
/** Start browser-based console login (fire-and-forget; UI polls status). */
|
||||
startConsoleLogin(): void;
|
||||
/** Clear all stored credentials. Returns whether anything changed. */
|
||||
logout(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Build the bridge from a command context (identity/settings/authStore). */
|
||||
export function makeAuthUiBridge(ctx: {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
authStore: AuthStore;
|
||||
}): AuthUiBridge {
|
||||
const { identity, settings, authStore } = ctx;
|
||||
return {
|
||||
status() {
|
||||
const a = authStore.describe();
|
||||
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
|
||||
let masked: string | undefined;
|
||||
if (a.console) masked = maskToken(a.console.token);
|
||||
else if (a.apiKey) masked = maskToken(a.apiKey.token);
|
||||
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
|
||||
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
|
||||
return {
|
||||
authenticated: methods.apiKey || methods.console || methods.openapi,
|
||||
methods,
|
||||
primary,
|
||||
region: a.console?.region,
|
||||
site: a.console?.site,
|
||||
masked,
|
||||
};
|
||||
},
|
||||
startConsoleLogin() {
|
||||
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
|
||||
// Mirror the CLI (`bl auth login --console`): request an api_key from the
|
||||
// console only when one isn't already stored, so a first console login in
|
||||
// the config UI also provisions the model api_key (not just access_token).
|
||||
const hasApiKey = !!authStore.stored().apiKey;
|
||||
// runConsoleLogin opens the browser and runs its own callback server
|
||||
// (up to 15 min). We don't await it — the config UI polls the status
|
||||
// endpoint to detect completion. Errors are logged, not surfaced.
|
||||
void runConsoleLogin(
|
||||
origin,
|
||||
{ identity, settings, authStore },
|
||||
{
|
||||
needApiKey: !hasApiKey,
|
||||
},
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`console login failed: ${msg}\n`);
|
||||
});
|
||||
},
|
||||
logout() {
|
||||
return authStore.logout("all");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function validateAndPersistApiKey(
|
||||
const persistBaseUrl = profile.persistBaseUrl
|
||||
? normalizeModelBaseUrl(profile.persistBaseUrl)
|
||||
: undefined;
|
||||
const validationModel = "qwen3.7-max";
|
||||
const validationModel = "qwen3.8-max";
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Best-effort local launcher for coding-agent CLIs surfaced in the config UI.
|
||||
*
|
||||
* The command for each agent is taken from a fixed allowlist keyed by the
|
||||
* agent id, so no user-controlled string is ever executed. Every child process
|
||||
* is spawned via `execFile` (array args, no shell) to avoid injection.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */
|
||||
export const AGENT_COMMANDS: Record<string, string> = {
|
||||
"claude-code": "claude",
|
||||
"qwen-code": "qwen",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
codex: "codex",
|
||||
};
|
||||
|
||||
/** The launch binary for a known agent id, or undefined when unknown. */
|
||||
export function agentCommand(id: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent argv that passes an initial task prompt while keeping the agent
|
||||
* interactive in the terminal. Only verified contracts are listed; an agent
|
||||
* absent here cannot be dispatched a prompt (its bare launch still works).
|
||||
* - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive)
|
||||
* - claude-code: `claude "<prompt>"` (positional initial prompt)
|
||||
* - codex: `codex "<prompt>"` (positional initial prompt)
|
||||
*/
|
||||
const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = {
|
||||
"qwen-code": (p) => ["-i", p],
|
||||
"claude-code": (p) => [p],
|
||||
codex: (p) => [p],
|
||||
};
|
||||
|
||||
/** Whether a known agent supports being dispatched an initial task prompt. */
|
||||
export function agentSupportsPrompt(id: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id);
|
||||
}
|
||||
|
||||
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
|
||||
function onPath(bin: string): Promise<boolean> {
|
||||
const cmd = process.platform === "win32" ? "where" : "which";
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a known agent can actually be quick-launched right now: its id maps to
|
||||
* a launch binary and that binary is reachable on PATH. Unknown ids resolve to
|
||||
* false. Used to gate the UI's Quick launch button so "Connected" agents whose
|
||||
* CLI is not installed do not offer a launch that would immediately fail.
|
||||
*/
|
||||
export function agentLaunchable(id: string): Promise<boolean> {
|
||||
const command = agentCommand(id);
|
||||
if (!command) return Promise.resolve(false);
|
||||
return onPath(command);
|
||||
}
|
||||
|
||||
/** Single-quote a path for a POSIX shell command line. */
|
||||
function shQuote(p: string): string {
|
||||
return `'${p.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */
|
||||
function spawnTerminal(command: string, cwd: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (platform === "darwin") {
|
||||
const inner = `cd ${shQuote(cwd)} && ${command}`;
|
||||
const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const args = [
|
||||
"-e",
|
||||
`tell application "Terminal" to do script "${escaped}"`,
|
||||
"-e",
|
||||
'tell application "Terminal" to activate',
|
||||
];
|
||||
execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
||||
return;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`];
|
||||
execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
||||
return;
|
||||
}
|
||||
// Linux / other: best-effort via the distro's default terminal emulator.
|
||||
const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`;
|
||||
execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) =>
|
||||
err ? reject(new Error("No supported terminal emulator was found")) : resolve(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface LaunchResult {
|
||||
launched: boolean;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a known coding agent's local CLI in a new terminal window. When
|
||||
* `prompt` is provided, it is passed as a single quoted argument using the
|
||||
* agent's verified prompt contract so the agent starts with that task.
|
||||
* Rejects when the id is unknown, the binary is missing from PATH, the agent
|
||||
* does not support prompt dispatch, or the platform terminal could not open.
|
||||
*/
|
||||
export async function launchAgent(
|
||||
id: string,
|
||||
cwd: string = process.cwd(),
|
||||
prompt?: string,
|
||||
): Promise<LaunchResult> {
|
||||
const command = agentCommand(id);
|
||||
if (!command) throw new Error(`Unknown agent: ${id}`);
|
||||
if (!(await onPath(command))) {
|
||||
throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`);
|
||||
}
|
||||
let fullCommand = command;
|
||||
const task = (prompt ?? "").trim();
|
||||
if (task) {
|
||||
const build = AGENT_PROMPT_ARGV[id];
|
||||
if (!build) throw new Error(`${id} does not support dispatching a task prompt.`);
|
||||
// shQuote keeps the whole prompt as one shell argument (no injection); the
|
||||
// platform terminal layer escapes the resulting command line separately.
|
||||
fullCommand = [command, ...build(task).map(shQuote)].join(" ");
|
||||
}
|
||||
await spawnTerminal(fullCommand, cwd);
|
||||
return { launched: true, command: fullCommand };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Read/manage the local assets that `bl` writes into the output directory
|
||||
// (default ~/bailian-output, overridable via the `output_dir` config key).
|
||||
// Generated media may live directly under the base or in any subfolder (bl's
|
||||
// own images/, videos/, speech/, omni/, or user-created folders). This module
|
||||
// recursively discovers every file under the base, classifies each by type,
|
||||
// derives its category from the top-level folder, and provides safe path
|
||||
// resolution for serving/deleting individual assets.
|
||||
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, extname, relative, resolve, sep } from "node:path";
|
||||
|
||||
export type AssetKind = "image" | "video" | "audio" | "other";
|
||||
|
||||
/** One generated file discovered under the output directory. */
|
||||
export interface AssetInfo {
|
||||
name: string;
|
||||
/** Category folder the file lives in: images | videos | speech | omni | other. */
|
||||
category: string;
|
||||
kind: AssetKind;
|
||||
/** Path relative to the output base (used as the API handle). */
|
||||
relPath: string;
|
||||
size: number;
|
||||
/** Modification time in epoch milliseconds ~= generation time. */
|
||||
mtime: number;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
/** Max directory depth to descend from the output base when scanning. */
|
||||
const MAX_SCAN_DEPTH = 8;
|
||||
|
||||
const KIND_BY_EXT: Record<string, AssetKind> = {
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".webp": "image",
|
||||
".gif": "image",
|
||||
".bmp": "image",
|
||||
".svg": "image",
|
||||
".mp4": "video",
|
||||
".mov": "video",
|
||||
".webm": "video",
|
||||
".mkv": "video",
|
||||
".avi": "video",
|
||||
".mp3": "audio",
|
||||
".wav": "audio",
|
||||
".m4a": "audio",
|
||||
".aac": "audio",
|
||||
".flac": "audio",
|
||||
".ogg": "audio",
|
||||
};
|
||||
|
||||
const CONTENT_TYPE: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".ogg": "audio/ogg",
|
||||
};
|
||||
|
||||
/** The default output base when `output_dir` is not configured. */
|
||||
export function defaultOutputBase(home: string = homedir()): string {
|
||||
return join(home, "bailian-output");
|
||||
}
|
||||
|
||||
function kindOf(ext: string): AssetKind {
|
||||
return KIND_BY_EXT[ext.toLowerCase()] ?? "other";
|
||||
}
|
||||
|
||||
/** MIME type for serving an asset; falls back to a safe binary type. */
|
||||
export function contentType(ext: string): string {
|
||||
return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Recursively collect regular files under `dir`, descending at most `depth` levels. */
|
||||
function walk(dir: string, depth: number, out: string[]): void {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (depth > 0) walk(full, depth - 1, out);
|
||||
} else if (e.isFile() || e.isSymbolicLink()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List generated assets under `base`, newest first. Recursively scans every
|
||||
* subfolder under the base (plus loose files at the root), so assets in bl's
|
||||
* own category dirs and any user-created folders are all discovered. Each
|
||||
* file's `category` is its top-level folder name, or "other" for root files.
|
||||
* Returns the resolved base so callers can surface it in the UI.
|
||||
*/
|
||||
export function listAssets(base: string = defaultOutputBase()): {
|
||||
base: string;
|
||||
assets: AssetInfo[];
|
||||
} {
|
||||
const assets: AssetInfo[] = [];
|
||||
if (!existsSync(base)) return { base, assets };
|
||||
|
||||
const files: string[] = [];
|
||||
walk(base, MAX_SCAN_DEPTH, files);
|
||||
|
||||
for (const full of files) {
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile()) continue;
|
||||
const rel = relative(base, full);
|
||||
const segments = rel.split(sep);
|
||||
const category = segments.length > 1 ? segments[0]! : "other";
|
||||
const ext = extname(full);
|
||||
assets.push({
|
||||
name: full.split(sep).pop() ?? full,
|
||||
category,
|
||||
kind: kindOf(ext),
|
||||
relPath: rel,
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
ext: ext.replace(/^\./, "").toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
assets.sort((a, b) => b.mtime - a.mtime);
|
||||
return { base, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client-supplied relative path to an absolute path strictly inside
|
||||
* `base`. Returns null for empty input or any path that would escape the base
|
||||
* (path traversal guard).
|
||||
*/
|
||||
export function resolveAssetPath(base: string, relPath: string): string | null {
|
||||
if (typeof relPath !== "string" || relPath.length === 0) return null;
|
||||
const root = resolve(base);
|
||||
const abs = resolve(root, relPath);
|
||||
if (abs !== root && !abs.startsWith(root + sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Minimal, dependency-free QR Code encoder used by the config UI to show a
|
||||
* scannable code for the current session URL.
|
||||
*
|
||||
* Scope is deliberately narrow: byte mode, error-correction level L, versions
|
||||
* 1–5 (21x21 … 37x37). Restricting to level L keeps every supported version a
|
||||
* single Reed–Solomon block, so no codeword interleaving is required. Version 5
|
||||
* (level L) holds up to 108 data bytes, comfortably more than a
|
||||
* `http://127.0.0.1:<port>/?token=<hex>` URL.
|
||||
*
|
||||
* The output is an SVG string with a 4-module quiet zone and a `viewBox` only
|
||||
* (no fixed width/height), so the caller sizes it via CSS.
|
||||
*/
|
||||
|
||||
// --- GF(256) arithmetic (primitive polynomial 0x11D) ---
|
||||
|
||||
const EXP = new Uint8Array(512);
|
||||
const LOG = new Uint8Array(256);
|
||||
(() => {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) {
|
||||
EXP[i] = x;
|
||||
LOG[x] = i;
|
||||
x <<= 1;
|
||||
if (x & 0x100) x ^= 0x11d;
|
||||
}
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
|
||||
function gmul(a: number, b: number): number {
|
||||
if (a === 0 || b === 0) return 0;
|
||||
return EXP[LOG[a] + LOG[b]];
|
||||
}
|
||||
|
||||
/** Reed–Solomon generator polynomial for `degree` EC codewords (alpha exponents). */
|
||||
export function rsGeneratorExp(degree: number): number[] {
|
||||
let poly = [1];
|
||||
for (let i = 0; i < degree; i++) {
|
||||
const next: number[] = Array.from({ length: poly.length + 1 }, () => 0);
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
next[j] ^= poly[j];
|
||||
next[j + 1] ^= gmul(poly[j], EXP[i]);
|
||||
}
|
||||
poly = next;
|
||||
}
|
||||
return poly.map((v) => LOG[v]);
|
||||
}
|
||||
|
||||
/** Compute `ecLen` Reed–Solomon error-correction codewords for `data`. */
|
||||
export function rsEncode(data: number[], ecLen: number): number[] {
|
||||
const gen = rsGeneratorExp(ecLen);
|
||||
const res = new Uint8Array(data.length + ecLen);
|
||||
res.set(data, 0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const coef = res[i];
|
||||
if (coef !== 0) {
|
||||
const lead = LOG[coef];
|
||||
for (let j = 0; j < gen.length; j++) res[i + j] ^= EXP[(gen[j] + lead) % 255];
|
||||
}
|
||||
}
|
||||
return Array.from(res.slice(data.length));
|
||||
}
|
||||
|
||||
// --- Capacity table: [data codewords, EC codewords] per version at level L ---
|
||||
|
||||
const CAP_L: Array<[number, number]> = [
|
||||
[19, 7], // V1 (21x21)
|
||||
[34, 10], // V2 (25x25)
|
||||
[55, 15], // V3 (29x29)
|
||||
[80, 20], // V4 (33x33)
|
||||
[108, 26], // V5 (37x37)
|
||||
];
|
||||
|
||||
const EC_BITS_L = 0b01; // format-info error-correction level bits for L
|
||||
|
||||
function pickVersion(byteLen: number): number {
|
||||
const bits = 4 + 8 + byteLen * 8; // mode + 8-bit count (V1–9) + payload
|
||||
for (let v = 0; v < CAP_L.length; v++) {
|
||||
if (CAP_L[v][0] * 8 >= bits) return v + 1;
|
||||
}
|
||||
throw new Error("qr: data too large for supported versions (max 108 bytes)");
|
||||
}
|
||||
|
||||
// --- Bit/codeword assembly ---
|
||||
|
||||
function toCodewords(bytes: Uint8Array, version: number): number[] {
|
||||
const [dataCw] = CAP_L[version - 1];
|
||||
const bits: number[] = [];
|
||||
const put = (val: number, len: number) => {
|
||||
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
|
||||
};
|
||||
put(0b0100, 4); // byte mode
|
||||
put(bytes.length, 8); // character count (versions 1–9)
|
||||
for (const b of bytes) put(b, 8);
|
||||
|
||||
const capBits = dataCw * 8;
|
||||
put(0, Math.min(4, capBits - bits.length)); // terminator
|
||||
while (bits.length % 8 !== 0) bits.push(0); // pad to byte
|
||||
|
||||
const data: number[] = [];
|
||||
for (let i = 0; i < bits.length; i += 8) {
|
||||
let v = 0;
|
||||
for (let j = 0; j < 8; j++) v = (v << 1) | bits[i + j];
|
||||
data.push(v);
|
||||
}
|
||||
const pads = [0xec, 0x11];
|
||||
for (let p = 0; data.length < dataCw; p++) data.push(pads[p % 2]);
|
||||
|
||||
return data.concat(rsEncode(data, CAP_L[version - 1][1]));
|
||||
}
|
||||
|
||||
// --- Matrix construction ---
|
||||
|
||||
interface Grid {
|
||||
size: number;
|
||||
mod: Uint8Array; // 0/1
|
||||
fn: Uint8Array; // 1 = function/reserved module (skip during data placement)
|
||||
}
|
||||
|
||||
function newGrid(size: number): Grid {
|
||||
return { size, mod: new Uint8Array(size * size), fn: new Uint8Array(size * size) };
|
||||
}
|
||||
|
||||
function setFn(g: Grid, r: number, c: number, dark: number): void {
|
||||
g.mod[r * g.size + c] = dark;
|
||||
g.fn[r * g.size + c] = 1;
|
||||
}
|
||||
|
||||
function drawFinder(g: Grid, r: number, c: number): void {
|
||||
for (let dr = -1; dr <= 7; dr++) {
|
||||
for (let dc = -1; dc <= 7; dc++) {
|
||||
const rr = r + dr;
|
||||
const cc = c + dc;
|
||||
if (rr < 0 || rr >= g.size || cc < 0 || cc >= g.size) continue;
|
||||
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
||||
const isDark =
|
||||
inRing &&
|
||||
(dr === 0 ||
|
||||
dr === 6 ||
|
||||
dc === 0 ||
|
||||
dc === 6 ||
|
||||
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
||||
setFn(g, rr, cc, isDark ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawAlignment(g: Grid, cr: number, cc: number): void {
|
||||
for (let dr = -2; dr <= 2; dr++) {
|
||||
for (let dc = -2; dc <= 2; dc++) {
|
||||
const ring = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
setFn(g, cr + dr, cc + dc, ring === 1 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFunctionPatterns(g: Grid, version: number): void {
|
||||
const size = g.size;
|
||||
// Timing patterns.
|
||||
for (let i = 0; i < size; i++) {
|
||||
setFn(g, 6, i, i % 2 === 0 ? 1 : 0);
|
||||
setFn(g, i, 6, i % 2 === 0 ? 1 : 0);
|
||||
}
|
||||
// Finder patterns + separators (drawn as the -1 border above).
|
||||
drawFinder(g, 0, 0);
|
||||
drawFinder(g, 0, size - 7);
|
||||
drawFinder(g, size - 7, 0);
|
||||
// Alignment pattern (single, centered) for versions 2–5.
|
||||
if (version >= 2) {
|
||||
const pos = size - 7; // e.g. 18 (V2), 22 (V3), 26 (V4), 30 (V5)
|
||||
drawAlignment(g, pos, pos);
|
||||
}
|
||||
// Reserve format-info areas (values written later).
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (!(i === 6)) g.fn[8 * size + i] = 1;
|
||||
if (!(i === 6)) g.fn[i * size + 8] = 1;
|
||||
}
|
||||
g.fn[8 * size + 6] = 1;
|
||||
g.fn[6 * size + 8] = 1;
|
||||
for (let i = 0; i < 8; i++) g.fn[(size - 1 - i) * size + 8] = 1;
|
||||
for (let i = 0; i < 8; i++) g.fn[8 * size + (size - 1 - i)] = 1;
|
||||
// Dark module.
|
||||
setFn(g, size - 8, 8, 1);
|
||||
}
|
||||
|
||||
function placeData(g: Grid, codewords: number[]): void {
|
||||
const size = g.size;
|
||||
const stream: number[] = [];
|
||||
for (const cw of codewords) for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1);
|
||||
let idx = 0;
|
||||
let upward = true;
|
||||
for (let col = size - 1; col >= 1; col -= 2) {
|
||||
if (col === 6) col = 5; // skip the vertical timing column
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = upward ? size - 1 - i : i;
|
||||
for (const off of [0, 1]) {
|
||||
const cc = col - off;
|
||||
if (g.fn[row * size + cc]) continue;
|
||||
g.mod[row * size + cc] = idx < stream.length ? stream[idx++] : 0;
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
}
|
||||
|
||||
const MASKS: Array<(r: number, c: number) => boolean> = [
|
||||
(r, c) => (r + c) % 2 === 0,
|
||||
(r) => r % 2 === 0,
|
||||
(_r, c) => c % 3 === 0,
|
||||
(r, c) => (r + c) % 3 === 0,
|
||||
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
||||
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
||||
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
];
|
||||
|
||||
function applyMask(g: Grid, mask: number): void {
|
||||
const cond = MASKS[mask];
|
||||
for (let r = 0; r < g.size; r++) {
|
||||
for (let c = 0; c < g.size; c++) {
|
||||
if (!g.fn[r * g.size + c] && cond(r, c)) g.mod[r * g.size + c] ^= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function penalty(g: Grid): number {
|
||||
const size = g.size;
|
||||
const at = (r: number, c: number) => g.mod[r * size + c];
|
||||
let score = 0;
|
||||
// Rule 1: runs of >=5 same-color modules in rows and columns.
|
||||
for (let r = 0; r < size; r++) {
|
||||
let runC = 1;
|
||||
let runR = 1;
|
||||
for (let c = 1; c < size; c++) {
|
||||
if (at(r, c) === at(r, c - 1)) runC++;
|
||||
else {
|
||||
if (runC >= 5) score += runC - 2;
|
||||
runC = 1;
|
||||
}
|
||||
if (at(c, r) === at(c - 1, r)) runR++;
|
||||
else {
|
||||
if (runR >= 5) score += runR - 2;
|
||||
runR = 1;
|
||||
}
|
||||
}
|
||||
if (runC >= 5) score += runC - 2;
|
||||
if (runR >= 5) score += runR - 2;
|
||||
}
|
||||
// Rule 2: 2x2 blocks of the same color.
|
||||
for (let r = 0; r < size - 1; r++) {
|
||||
for (let c = 0; c < size - 1; c++) {
|
||||
const v = at(r, c);
|
||||
if (v === at(r, c + 1) && v === at(r + 1, c) && v === at(r + 1, c + 1)) score += 3;
|
||||
}
|
||||
}
|
||||
// Rule 3: finder-like 1:1:3:1:1 patterns.
|
||||
const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
|
||||
const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
|
||||
const match = (get: (k: number) => number, start: number, pat: number[]) => {
|
||||
for (let k = 0; k < pat.length; k++) if (get(start + k) !== pat[k]) return false;
|
||||
return true;
|
||||
};
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c <= size - 11; c++) {
|
||||
if (match((k) => at(r, k), c, pat1) || match((k) => at(r, k), c, pat2)) score += 40;
|
||||
if (match((k) => at(k, r), c, pat1) || match((k) => at(k, r), c, pat2)) score += 40;
|
||||
}
|
||||
}
|
||||
// Rule 4: proportion of dark modules.
|
||||
let dark = 0;
|
||||
for (let i = 0; i < size * size; i++) dark += g.mod[i];
|
||||
const percent = (dark * 100) / (size * size);
|
||||
const k = Math.floor(Math.abs(percent - 50) / 5);
|
||||
score += k * 10;
|
||||
return score;
|
||||
}
|
||||
|
||||
function formatBits(mask: number): number {
|
||||
const data = (EC_BITS_L << 3) | mask; // 5 bits
|
||||
let rem = data << 10;
|
||||
for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
|
||||
return ((data << 10) | rem) ^ 0x5412;
|
||||
}
|
||||
|
||||
function drawFormat(g: Grid, mask: number): void {
|
||||
const size = g.size;
|
||||
const fmt = formatBits(mask);
|
||||
const bit = (i: number) => (fmt >> i) & 1;
|
||||
// First copy: around the top-left finder. Bits 0–5 run down column 8
|
||||
// (rows 0–5); bits 9–14 run left along row 8 (cols 5–0).
|
||||
for (let i = 0; i <= 5; i++) g.mod[i * size + 8] = bit(i);
|
||||
g.mod[7 * size + 8] = bit(6);
|
||||
g.mod[8 * size + 8] = bit(7);
|
||||
g.mod[8 * size + 7] = bit(8);
|
||||
for (let i = 9; i < 15; i++) g.mod[8 * size + (14 - i)] = bit(i);
|
||||
// Second copy: split across top-right and bottom-left.
|
||||
for (let i = 0; i < 8; i++) g.mod[(size - 1 - i) * size + 8] = bit(i);
|
||||
for (let i = 8; i < 15; i++) g.mod[8 * size + (size - 15 + i)] = bit(i);
|
||||
g.mod[(size - 8) * size + 8] = 1; // dark module stays set
|
||||
}
|
||||
|
||||
/** Build the final QR module matrix (true = dark) for `text`. */
|
||||
export function qrMatrix(text: string): boolean[][] {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const version = pickVersion(bytes.length);
|
||||
const codewords = toCodewords(bytes, version);
|
||||
const g = newGrid(17 + 4 * version);
|
||||
drawFunctionPatterns(g, version);
|
||||
placeData(g, codewords);
|
||||
|
||||
let best = 0;
|
||||
let bestScore = Infinity;
|
||||
for (let m = 0; m < 8; m++) {
|
||||
applyMask(g, m);
|
||||
drawFormat(g, m);
|
||||
const s = penalty(g);
|
||||
if (s < bestScore) {
|
||||
bestScore = s;
|
||||
best = m;
|
||||
}
|
||||
applyMask(g, m); // undo (XOR is its own inverse)
|
||||
}
|
||||
applyMask(g, best);
|
||||
drawFormat(g, best);
|
||||
|
||||
const out: boolean[][] = [];
|
||||
for (let r = 0; r < g.size; r++) {
|
||||
const row: boolean[] = [];
|
||||
for (let c = 0; c < g.size; c++) row.push(g.mod[r * g.size + c] === 1);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render `text` as an SVG QR code string (4-module quiet zone, viewBox only). */
|
||||
export function qrSvg(text: string): string {
|
||||
const m = qrMatrix(text);
|
||||
const size = m.length;
|
||||
const quiet = 4;
|
||||
const dim = size + quiet * 2;
|
||||
let rects = "";
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (m[r][c]) rects += `<rect x="${c + quiet}" y="${r + quiet}" width="1" height="1"/>`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
|
||||
`shape-rendering="crispEdges" role="img" aria-label="QR code">` +
|
||||
`<rect width="${dim}" height="${dim}" fill="#ffffff"/>` +
|
||||
`<g fill="#000000">${rects}</g></svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Curated "Playground" scenarios surfaced in the config UI.
|
||||
*
|
||||
* Each scenario is a fixed, reviewable prompt template that the UI can dispatch
|
||||
* to a connected local coding agent (e.g. qwen-code), which then runs it in a
|
||||
* new terminal. Optional `{{inputs}}` are filled by the user before dispatch.
|
||||
*
|
||||
* Prompts are defined here and never accepted as free-form text from the web,
|
||||
* so the instruction handed to a local agent is always known and auditable.
|
||||
*/
|
||||
export interface ScenarioInput {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
prompt: string;
|
||||
inputs?: ScenarioInput[];
|
||||
}
|
||||
|
||||
export const SCENARIOS: Scenario[] = [
|
||||
// ---- 图像 ----
|
||||
{
|
||||
id: "image-generate",
|
||||
title: "文生图",
|
||||
description: "一键生成一张示例图片并保存到输出目录。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。",
|
||||
},
|
||||
{
|
||||
id: "image-describe",
|
||||
title: "图片理解",
|
||||
description: "从输出目录任选一张图片,详细描述内容与风格。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中任选一张图片,用中文详细描述它的内容、主体、构图、色彩与风格,并推测它适合的使用场景。若目录为空请说明。",
|
||||
},
|
||||
{
|
||||
id: "image-alt-batch",
|
||||
title: "批量 Alt 文本",
|
||||
description: "为输出目录下的图片批量生成无障碍 alt 文本。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请扫描输出目录(默认 output/images)下的所有图片,逐张生成简洁、准确的 alt 无障碍描述,最后以「文件名 → alt 文本」的表格汇总。若目录为空请说明。",
|
||||
},
|
||||
{
|
||||
id: "image-to-code",
|
||||
title: "截图转代码",
|
||||
description: "把输出目录里的界面截图还原成 HTML+CSS。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中查找一张界面截图,用 HTML + CSS 尽可能还原它的布局、间距与配色,输出为一个可直接在浏览器打开的单文件,并简述还原思路。若没有找到截图请说明。",
|
||||
},
|
||||
// ---- 音频 ----
|
||||
{
|
||||
id: "speech-generate",
|
||||
title: "文字转语音",
|
||||
description: "把一句示例文字合成为自然语音。",
|
||||
category: "音频",
|
||||
prompt:
|
||||
"请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。",
|
||||
},
|
||||
{
|
||||
id: "audio-summarize",
|
||||
title: "音频转写总结",
|
||||
description: "转写输出目录里的音频并提炼要点。",
|
||||
category: "音频",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/speech)中找到一个音频文件,转写其内容,先给出完整文字,再用要点列表总结关键信息。若目录为空或缺少转写能力,请说明并尝试用可用的能力完成。",
|
||||
},
|
||||
// ---- 视频 ----
|
||||
{
|
||||
id: "video-generate",
|
||||
title: "文生视频",
|
||||
description: "一键生成一段示例短视频。",
|
||||
category: "视频",
|
||||
prompt:
|
||||
"请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。",
|
||||
},
|
||||
{
|
||||
id: "video-storyboard",
|
||||
title: "视频分镜脚本",
|
||||
description: "围绕示例主题产出可用于文生视频的分镜。",
|
||||
category: "视频",
|
||||
prompt:
|
||||
"围绕主题「城市清晨的第一杯咖啡」,为一支 15-30 秒的短视频撰写分镜脚本:逐镜头给出画面描述、时长、字幕或旁白,并为每个镜头附上可直接用于文生视频的英文 prompt。",
|
||||
},
|
||||
// ---- 多模态 ----
|
||||
{
|
||||
id: "media-prompt-craft",
|
||||
title: "多模态提示词",
|
||||
description: "把一个示例创意扩展成图/视频/语音提示词。",
|
||||
category: "多模态",
|
||||
prompt:
|
||||
"把创意「未来赛博城市的夜市」扩展成三组高质量生成提示词:1) 文生图;2) 文生视频;3) 语音风格描述。每组给出中英对照,并简要说明关键参数建议。",
|
||||
},
|
||||
{
|
||||
id: "image-story-narration",
|
||||
title: "图片配音文案",
|
||||
description: "为输出目录里的图片写解说词并给出可合成文本。",
|
||||
category: "多模态",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中任选一张图片,为它撰写一段 60 秒左右的中文解说词(适合配音),语气生动。随后给出可直接用于语音合成的纯文本版本。若目录为空请说明。",
|
||||
},
|
||||
// ---- 代码 ----
|
||||
{
|
||||
id: "summarize-project",
|
||||
title: "总结当前项目",
|
||||
description: "让 agent 阅读当前目录,总结架构、技术栈与主要模块。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请阅读当前工作目录的项目结构和关键源码,用简洁的中文总结:1) 它是做什么的;2) 技术栈;3) 主要模块及其职责;4) 值得注意的设计。先浏览再下结论,不要臆测。",
|
||||
},
|
||||
{
|
||||
id: "write-tests",
|
||||
title: "为核心模块写单测",
|
||||
description: "自动挑选缺测试的核心模块并补全单元测试。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请在当前项目中挑选一个核心且缺少测试(或测试薄弱)的模块,为它编写全面的单元测试,覆盖主要逻辑分支和边界情况,并遵循本项目现有的测试框架与风格。先阅读相关文件及其依赖,再编写测试。",
|
||||
},
|
||||
{
|
||||
id: "code-review",
|
||||
title: "代码审查",
|
||||
description: "审查当前项目核心代码,指出问题与改进建议。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请审查当前项目的核心源码,指出潜在的 bug、安全隐患、性能与可维护性问题,并给出具体、可操作的改进建议,按严重程度排序。先浏览项目结构,选取关键文件再审查。",
|
||||
},
|
||||
{
|
||||
id: "explain-code",
|
||||
title: "解释核心代码",
|
||||
description: "挑选入口或核心模块,解释其实现与依赖。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请挑选当前项目的入口文件或核心模块,解释它的实现:职责是什么、关键流程如何运转、依赖了哪些模块。用清晰的中文说明,必要时给出调用关系。",
|
||||
},
|
||||
// ---- 文档 ----
|
||||
{
|
||||
id: "generate-readme",
|
||||
title: "生成 README",
|
||||
description: "阅读代码后生成结构清晰、与实现一致的 README.md。",
|
||||
category: "文档",
|
||||
prompt:
|
||||
"为当前工作目录的项目生成一个结构清晰的 README.md,包含:项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写,内容必须与实际实现一致。",
|
||||
},
|
||||
];
|
||||
|
||||
/** Look up a scenario by id, or undefined when unknown. */
|
||||
export function getScenario(id: string): Scenario | undefined {
|
||||
return SCENARIOS.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */
|
||||
export function renderScenarioPrompt(scenario: Scenario, values: Record<string, string>): string {
|
||||
return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
|
||||
const v = values[key];
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,80 @@ export const SECRET_KEYS = new Set<string>([
|
||||
"security_token",
|
||||
]);
|
||||
|
||||
// The web UI edits the full ConfigFile, so it exposes these extra keys on top
|
||||
// of VALID_KEYS (which `config set` keeps as its narrower, documented surface).
|
||||
// This lets `config ui` surface and edit every field that lives in config.json
|
||||
// rather than silently hiding console/telemetry settings.
|
||||
export const UI_EXTRA_KEYS = [
|
||||
"console_site",
|
||||
"console_region",
|
||||
"console_switch_agent",
|
||||
"telemetry",
|
||||
] as const;
|
||||
|
||||
export const UI_VALID_KEYS = [...VALID_KEYS, ...UI_EXTRA_KEYS] as const;
|
||||
|
||||
// Keys the UI renders as a fixed-choice dropdown instead of a free-text input.
|
||||
export const UI_ENUM_KEYS: Record<string, string[]> = {
|
||||
output: ["text", "json"],
|
||||
console_site: ["domestic", "international"],
|
||||
};
|
||||
|
||||
// Keys the UI renders as a true/false dropdown and stores as a boolean.
|
||||
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
|
||||
|
||||
// Default model each `default_*_model` key falls back to when left unset. These
|
||||
// mirror the inline `|| "<model>"` fallbacks in the generation commands
|
||||
// (text/chat, image/generate, video/generate, speech/synthesize, omni/chat) and
|
||||
// are surfaced as input placeholders so users can see the effective default
|
||||
// without persisting a value that would pin the model.
|
||||
export const UI_MODEL_DEFAULTS: Record<string, string> = {
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_image_model: "qwen-image-3.0",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_speech_model: "cosyvoice-v3-flash",
|
||||
default_omni_model: "qwen3.5-omni-plus",
|
||||
};
|
||||
|
||||
/** One selectable model plus a short note on where the CLI uses it. */
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// A per-category catalog of the model names the `bl` pipeline actually
|
||||
// references (packages/runtime/src/pipeline/steps/bl-api.ts, plus the advisor
|
||||
// and agent-writer helpers). The UI groups these under each `default_*_model`
|
||||
// field as click-to-fill suggestions; the first entry is the fallback default.
|
||||
// Only names present in the codebase are listed here — no invented models.
|
||||
export const UI_MODEL_CATALOG: Record<string, ModelOption[]> = {
|
||||
default_text_model: [
|
||||
{ id: "qwen3.8-max", role: "text/chat default" },
|
||||
{ id: "qwen3-coder-plus", role: "coding-oriented (agent config)" },
|
||||
{ id: "qwen-flash", role: "fast · advisor ranking" },
|
||||
{ id: "qwen3.6-flash", role: "fast · advisor intent" },
|
||||
],
|
||||
default_image_model: [
|
||||
{ id: "qwen-image-3.0", role: "image/generate default · sync" },
|
||||
{ id: "qwen-image-2.0", role: "image/generate · sync" },
|
||||
{ id: "qwen-image-max", role: "image/generate · sync" },
|
||||
{ id: "qwen-image-edit-2.0", role: "image/edit · sync" },
|
||||
{ id: "wanx2.x", role: "image/generate · async series" },
|
||||
],
|
||||
default_video_model: [
|
||||
{ id: "happyhorse-1.1-t2v", role: "video/generate default · text-to-video" },
|
||||
{ id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" },
|
||||
],
|
||||
default_speech_model: [
|
||||
{ id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" },
|
||||
{ id: "fun-asr", role: "speech/recognize (ASR)" },
|
||||
],
|
||||
default_omni_model: [
|
||||
{ id: "qwen3.5-omni-plus", role: "omni/chat default" },
|
||||
{ id: "qwen3-vl-plus", role: "vision/describe · multimodal input" },
|
||||
],
|
||||
};
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
|
||||
export const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
@@ -92,3 +166,55 @@ export function validateAndCoerce(key: string, value: string): string | number {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate/coerce a value for the wider set of keys the web UI can edit
|
||||
* (UI_VALID_KEYS). Standard keys delegate to `validateAndCoerce`; the UI-only
|
||||
* extras (console_*, telemetry) are validated here. Booleans are returned as
|
||||
* real booleans so they persist correctly in config.json.
|
||||
*/
|
||||
export function validateAndCoerceUi(key: string, value: string): string | number | boolean {
|
||||
const resolvedKey = resolveKey(key);
|
||||
|
||||
if ((VALID_KEYS as readonly string[]).includes(resolvedKey)) {
|
||||
return validateAndCoerce(key, value);
|
||||
}
|
||||
|
||||
if (resolvedKey === "console_site") {
|
||||
if (!["domestic", "international"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid console_site "${value}". Valid values: domestic, international`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (resolvedKey === "console_region") return value;
|
||||
|
||||
if (resolvedKey === "console_switch_agent") {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid console_switch_agent "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
if (resolvedKey === "telemetry") {
|
||||
if (value !== "true" && value !== "false") {
|
||||
throw new BailianError(
|
||||
`Invalid telemetry "${value}". Valid values: true, false`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${UI_VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,7 @@
|
||||
import http from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import {
|
||||
defineCommand,
|
||||
@@ -10,13 +12,38 @@ import {
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
deleteConfigProfile,
|
||||
REGIONS,
|
||||
type ConfigStore,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { listenLocalServer, openInBrowser, openPath } from "../shared/local-server.ts";
|
||||
import { PAGE_HTML } from "./ui-html.ts";
|
||||
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
import {
|
||||
UI_VALID_KEYS,
|
||||
UI_ENUM_KEYS,
|
||||
UI_BOOLEAN_KEYS,
|
||||
UI_MODEL_DEFAULTS,
|
||||
UI_MODEL_CATALOG,
|
||||
SECRET_KEYS,
|
||||
resolveKey,
|
||||
validateAndCoerceUi,
|
||||
} from "./shared.ts";
|
||||
import {
|
||||
listSkills,
|
||||
listMcpServers,
|
||||
listAgents,
|
||||
getSkillDetail,
|
||||
getAgentDetail,
|
||||
writeMcpServer,
|
||||
deleteMcpServer,
|
||||
installSkillZip,
|
||||
} from "./inventory.ts";
|
||||
import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts";
|
||||
import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts";
|
||||
import { qrSvg } from "./qr.ts";
|
||||
import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts";
|
||||
import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts";
|
||||
|
||||
const FLAGS = {
|
||||
port: {
|
||||
@@ -50,6 +77,7 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
@@ -59,16 +87,47 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Max size for binary uploads (skill .zip packages). */
|
||||
const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB
|
||||
|
||||
function readBodyBuffer(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > max) {
|
||||
reject(new Error("payload too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Constant-time token comparison (avoids timing side channels). */
|
||||
function tokenMatches(provided: string | null, expected: string): boolean {
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Build the request cleaned/validated config block from a posted `data` map. */
|
||||
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
|
||||
const cleaned: Record<string, string | number> = {};
|
||||
function buildProfilePatch(
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, string | number | boolean> {
|
||||
const cleaned: Record<string, string | number | boolean> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
let value = "";
|
||||
if (typeof v === "string") value = v;
|
||||
else if (typeof v === "number" || typeof v === "boolean") value = String(v);
|
||||
// null/undefined/objects fall through as "" and clear the key
|
||||
if (value === "") continue;
|
||||
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
|
||||
cleaned[resolveKey(k)] = validateAndCoerceUi(k, value);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
@@ -76,9 +135,9 @@ function buildProfilePatch(data: Record<string, unknown>): Record<string, string
|
||||
/** Preserve valid Config fields that the UI does not expose or manage. */
|
||||
function mergeUnmanagedProfileFields(
|
||||
existing: Record<string, unknown>,
|
||||
managedPatch: Record<string, string | number>,
|
||||
managedPatch: Record<string, string | number | boolean>,
|
||||
): Record<string, unknown> {
|
||||
const managedKeys = new Set<string>(VALID_KEYS);
|
||||
const managedKeys = new Set<string>(UI_VALID_KEYS);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (!managedKeys.has(key)) merged[key] = value;
|
||||
@@ -91,7 +150,12 @@ function mergeUnmanagedProfileFields(
|
||||
* - Host header must be a loopback name (anti DNS-rebinding).
|
||||
* - every request must carry `?token=` matching the session token.
|
||||
*/
|
||||
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
|
||||
export function createConfigUiServer(
|
||||
token: string,
|
||||
configStore: ConfigStore,
|
||||
outputBase: string = defaultOutputBase(),
|
||||
authBridge?: AuthUiBridge,
|
||||
): http.Server {
|
||||
return http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0];
|
||||
@@ -102,7 +166,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("token") !== token) {
|
||||
if (!tokenMatches(u.searchParams.get("token"), token)) {
|
||||
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("unauthorized\n");
|
||||
return;
|
||||
@@ -112,17 +176,54 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
const path = u.pathname;
|
||||
|
||||
if (path === "/" && method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
// The page URL carries the session token, so never cache it.
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " +
|
||||
"media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
});
|
||||
res.end(PAGE_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/qr" && method === "GET") {
|
||||
const data = (u.searchParams.get("data") ?? "").slice(0, 512);
|
||||
if (!data) {
|
||||
sendJson(res, 400, { error: "missing data" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const svg = qrSvg(data);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(svg);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/config" && method === "GET") {
|
||||
const profiles = configStore.profiles();
|
||||
sendJson(res, 200, {
|
||||
configFile: configStore.path,
|
||||
keys: VALID_KEYS,
|
||||
keys: UI_VALID_KEYS,
|
||||
secretKeys: [...SECRET_KEYS],
|
||||
enums: UI_ENUM_KEYS,
|
||||
booleanKeys: [...UI_BOOLEAN_KEYS],
|
||||
fieldDefaults: {
|
||||
...UI_MODEL_DEFAULTS,
|
||||
base_url: REGIONS.cn,
|
||||
output_dir: defaultOutputBase(),
|
||||
timeout: "300",
|
||||
},
|
||||
modelCatalog: UI_MODEL_CATALOG,
|
||||
activeProfile: profiles.active,
|
||||
default: profiles.default,
|
||||
named: profiles.named,
|
||||
@@ -130,6 +231,342 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skills" && method === "GET") {
|
||||
sendJson(res, 200, { skills: listSkills() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skill" && method === "GET") {
|
||||
const detail = getSkillDetail(u.searchParams.get("id") ?? "");
|
||||
if (!detail) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skill/install" && method === "POST") {
|
||||
const source = u.searchParams.get("source") ?? "";
|
||||
const name = u.searchParams.get("name") ?? "";
|
||||
try {
|
||||
const buf = await readBodyBuffer(req, MAX_UPLOAD);
|
||||
const result = installSkillZip(source, buf, name);
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "GET") {
|
||||
sendJson(res, 200, { servers: listMcpServers() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as {
|
||||
source?: unknown;
|
||||
scope?: unknown;
|
||||
name?: unknown;
|
||||
config?: unknown;
|
||||
};
|
||||
const source = typeof body.source === "string" ? body.source : "";
|
||||
const scope = typeof body.scope === "string" && body.scope ? body.scope : "global";
|
||||
const name = typeof body.name === "string" ? body.name : "";
|
||||
try {
|
||||
writeMcpServer(source, scope, name, body.config);
|
||||
sendJson(res, 200, { saved: name.trim() });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "DELETE") {
|
||||
const source = u.searchParams.get("source") ?? "";
|
||||
const scope = u.searchParams.get("scope") || "global";
|
||||
const name = u.searchParams.get("name") ?? "";
|
||||
try {
|
||||
deleteMcpServer(source, scope, name);
|
||||
sendJson(res, 200, { deleted: name });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/health" && method === "GET") {
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
sendJson(res, 200, {
|
||||
node: process.version,
|
||||
nodeOk: Number.isFinite(major) && major >= 18,
|
||||
platform: process.platform,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agents" && method === "GET") {
|
||||
// Augment each agent with `launchable`: whether its CLI binary is on
|
||||
// PATH. "Connected" only means bl is wired into the agent's config, so
|
||||
// the UI uses this to avoid offering a launch that would instantly fail.
|
||||
// `dispatchable` additionally requires a verified prompt contract.
|
||||
const agents = listAgents();
|
||||
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
|
||||
sendJson(res, 200, {
|
||||
agents: agents.map((a, i) => ({
|
||||
...a,
|
||||
launchable: launchable[i],
|
||||
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent" && method === "GET") {
|
||||
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
|
||||
if (!detail) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/open" && method === "POST") {
|
||||
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
|
||||
const target = u.searchParams.get("path") ?? "";
|
||||
const allowed = detail?.settings.some((s) => s.path === target) ?? false;
|
||||
if (!detail || !allowed || !existsSync(target)) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openPath(target);
|
||||
sendJson(res, 200, { opened: target });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/scenarios" && method === "GET") {
|
||||
// Curated Playground scenarios plus the connected agents that can be
|
||||
// dispatched a prompt right now (on PATH + verified prompt contract).
|
||||
const agents = listAgents();
|
||||
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
|
||||
const targets = agents
|
||||
.map((a, i) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
|
||||
}))
|
||||
.filter((a) => a.dispatchable);
|
||||
sendJson(res, 200, { scenarios: SCENARIOS, agents: targets });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/status" && method === "GET") {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
authBridge
|
||||
? authBridge.status()
|
||||
: {
|
||||
authenticated: false,
|
||||
methods: { apiKey: false, console: false, openapi: false },
|
||||
primary: null,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" && method === "POST") {
|
||||
if (!authBridge) {
|
||||
sendJson(res, 400, { error: "login unavailable" });
|
||||
return;
|
||||
}
|
||||
authBridge.startConsoleLogin();
|
||||
sendJson(res, 200, { started: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout" && method === "POST") {
|
||||
if (!authBridge) {
|
||||
sendJson(res, 400, { error: "logout unavailable" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loggedOut = await authBridge.logout();
|
||||
sendJson(res, 200, { loggedOut });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/assets" && method === "GET") {
|
||||
sendJson(res, 200, listAssets(outputBase));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset/file" && method === "GET") {
|
||||
const abs = resolveAssetPath(outputBase, u.searchParams.get("path") ?? "");
|
||||
const st = abs && existsSync(abs) ? statSync(abs) : null;
|
||||
if (!abs || !st || !st.isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": contentType(extname(abs)),
|
||||
"Content-Length": st.size,
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
const stream = createReadStream(abs);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset" && method === "DELETE") {
|
||||
const rel = u.searchParams.get("path") ?? "";
|
||||
const abs = resolveAssetPath(outputBase, rel);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unlinkSync(abs);
|
||||
sendJson(res, 200, { deleted: rel });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset/open" && method === "POST") {
|
||||
const rel = u.searchParams.get("path") ?? "";
|
||||
const abs = resolveAssetPath(outputBase, rel);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openPath(abs);
|
||||
sendJson(res, 200, { opened: rel });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/launch" && method === "POST") {
|
||||
try {
|
||||
const result = await launchAgent(u.searchParams.get("id") ?? "");
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/dispatch" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as {
|
||||
scenario?: unknown;
|
||||
agent?: unknown;
|
||||
values?: unknown;
|
||||
custom?: unknown;
|
||||
};
|
||||
const agentId = typeof body.agent === "string" ? body.agent : "";
|
||||
if (!agentSupportsPrompt(agentId)) {
|
||||
sendJson(res, 400, { error: "agent cannot be dispatched a prompt" });
|
||||
return;
|
||||
}
|
||||
let scenario: Scenario | undefined;
|
||||
const custom = body.custom;
|
||||
if (custom && typeof custom === "object" && !Array.isArray(custom)) {
|
||||
const c = custom as { title?: unknown; prompt?: unknown; inputs?: unknown };
|
||||
const promptTpl = typeof c.prompt === "string" ? c.prompt.trim() : "";
|
||||
if (!promptTpl) {
|
||||
sendJson(res, 400, { error: "custom scenario needs a prompt" });
|
||||
return;
|
||||
}
|
||||
const inputs: { key: string; label: string }[] = [];
|
||||
if (Array.isArray(c.inputs)) {
|
||||
for (const it of c.inputs as unknown[]) {
|
||||
if (it && typeof it === "object") {
|
||||
const o = it as { key?: unknown; label?: unknown };
|
||||
const key = typeof o.key === "string" ? o.key.trim() : "";
|
||||
if (key) {
|
||||
const label =
|
||||
typeof o.label === "string" && o.label.trim() ? o.label.trim() : key;
|
||||
inputs.push({ key, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scenario = {
|
||||
id: "custom",
|
||||
title: typeof c.title === "string" && c.title.trim() ? c.title.trim() : "Custom",
|
||||
description: "",
|
||||
category: "\u81ea\u5b9a\u4e49",
|
||||
prompt: promptTpl,
|
||||
inputs,
|
||||
};
|
||||
} else {
|
||||
scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined;
|
||||
}
|
||||
if (!scenario) {
|
||||
sendJson(res, 400, { error: "unknown scenario" });
|
||||
return;
|
||||
}
|
||||
const values: Record<string, string> = {};
|
||||
if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) {
|
||||
for (const [k, v] of Object.entries(body.values as Record<string, unknown>)) {
|
||||
if (typeof v === "string") values[k] = v;
|
||||
}
|
||||
}
|
||||
for (const inp of scenario.inputs ?? []) {
|
||||
if (!values[inp.key] || !values[inp.key]!.trim()) {
|
||||
sendJson(res, 400, { error: `Missing input: ${inp.label}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const prompt = renderScenarioPrompt(scenario, values);
|
||||
try {
|
||||
const result = await launchAgent(agentId, process.cwd(), prompt);
|
||||
sendJson(res, 200, {
|
||||
launched: true,
|
||||
agent: agentId,
|
||||
scenario: scenario.id,
|
||||
command: result.command,
|
||||
});
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/active" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
@@ -164,7 +601,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
return;
|
||||
}
|
||||
let normalized: string | undefined;
|
||||
let cleaned: Record<string, string | number>;
|
||||
let cleaned: Record<string, string | number | boolean>;
|
||||
try {
|
||||
normalized = normalizeConfigName(body.name);
|
||||
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
|
||||
@@ -191,9 +628,15 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found\n");
|
||||
} catch {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// Log server-side so failures are diagnosable, and return a JSON error
|
||||
// instead of an empty 500 body.
|
||||
console.error("[config ui] request failed:", err);
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, { error: errMessage(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -217,9 +660,29 @@ export default defineCommand({
|
||||
routes: [
|
||||
"GET / -> web UI",
|
||||
"GET /api/config -> read all profiles",
|
||||
"GET /api/skills -> list installed agent skills",
|
||||
"GET /api/skill -> read one skill's SKILL.md detail",
|
||||
"POST /api/skill/install -> install a skill from an uploaded .zip into a skills root",
|
||||
"GET /api/mcp -> list local MCP servers",
|
||||
"POST /api/mcp -> create or update one MCP server (writes its source config)",
|
||||
"DELETE /api/mcp -> remove one MCP server from its source config",
|
||||
"GET /api/health -> runtime environment info (node, platform, cwd)",
|
||||
"GET /api/agents -> list coding agent frameworks",
|
||||
"GET /api/agent -> one agent's config detail (secrets masked)",
|
||||
"POST /api/agent/open -> open one agent's config file with the OS default app",
|
||||
"GET /api/auth/status -> current auth state",
|
||||
"POST /api/auth/login -> start console login (opens browser)",
|
||||
"POST /api/auth/logout -> clear all stored credentials",
|
||||
"GET /api/assets -> list generated assets",
|
||||
"GET /api/asset/file -> stream one asset file",
|
||||
"POST /api/asset/open -> open one asset with the OS default app",
|
||||
"POST /api/agent/launch -> launch a coding agent CLI in a new terminal",
|
||||
"GET /api/scenarios -> list Playground scenarios and dispatchable agents",
|
||||
"POST /api/agent/dispatch -> dispatch a scenario prompt to a connected agent",
|
||||
"POST /api/profile -> save a profile",
|
||||
"POST /api/active -> activate a profile",
|
||||
"DELETE /api/profile -> delete a named profile",
|
||||
"DELETE /api/asset -> delete one asset file",
|
||||
],
|
||||
},
|
||||
format,
|
||||
@@ -228,7 +691,8 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const server = createConfigUiServer(token, ctx.configStore);
|
||||
const outputBase = settings.outputDir || defaultOutputBase();
|
||||
const server = createConfigUiServer(token, ctx.configStore, outputBase, makeAuthUiBridge(ctx));
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
fileId: {
|
||||
@@ -30,6 +30,7 @@ export default defineCommand({
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(`Deleted ${fileId}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
fileId: {
|
||||
@@ -46,7 +46,7 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
emitResult({ ...item, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,5 +58,6 @@ export default defineCommand({
|
||||
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.description) emitBare(`description: ${item.description}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -55,7 +55,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,5 +68,6 @@ export default defineCommand({
|
||||
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
MAX_MEDIA_ZIP_BYTES,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type DatasetFile,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const UPLOAD_FLAGS = {
|
||||
file: {
|
||||
@@ -135,17 +134,19 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded: DatasetFile = await uploadDataset(ctx.client, {
|
||||
const uploaded = await uploadDataset(ctx.client, {
|
||||
filePath,
|
||||
purpose,
|
||||
});
|
||||
const { request_id, ...file } = uploaded;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(uploaded.file_id);
|
||||
emitBare(file.file_id);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
|
||||
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
|
||||
emitRequestId(request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(uploaded, format);
|
||||
emitResult({ ...file, request_id }, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type CommandContext,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
model: {
|
||||
@@ -163,6 +163,7 @@ async function runCreate(
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -71,6 +71,7 @@ export default defineCommand({
|
||||
emitBare(deployedModel);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${deployedModel}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -58,7 +58,7 @@ export default defineCommand({
|
||||
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
emitResult({ ...item, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,5 +69,6 @@ export default defineCommand({
|
||||
const display = typeof value === "string" ? value : JSON.stringify(value);
|
||||
emitBare(`${label(key)}${display}`);
|
||||
}
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listDeployments,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -58,7 +58,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,5 +78,6 @@ export default defineCommand({
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listDeployableModels,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const MODELS_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -122,7 +122,7 @@ export default defineCommand({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,5 +168,6 @@ export default defineCommand({
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
scaleDeployment,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const SCALE_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -72,6 +72,7 @@ export default defineCommand({
|
||||
} else if (format === "text") {
|
||||
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
|
||||
emitBare(`Scaled ${deployedModel}${cap}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
updateDeployment,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const UPDATE_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -70,6 +70,7 @@ export default defineCommand({
|
||||
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
|
||||
const summary = parts.length ? ` (${parts.join(", ")})` : "";
|
||||
emitBare(`Updated ${deployedModel}${summary}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default defineCommand({
|
||||
"--file photo.jpg --model qwen3-vl-plus",
|
||||
"--file video.mp4 --model wan2.1-t2v-plus",
|
||||
"--file audio.wav --model qwen3-asr-flash",
|
||||
"--file cat.png --model qwen-image-2.0",
|
||||
"--file cat.png --model qwen-image-3.0",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const CANCEL_FLAGS = {
|
||||
jobId: {
|
||||
@@ -38,6 +38,7 @@ export default defineCommand({
|
||||
} else if (format === "text") {
|
||||
const status = job?.status ? ` (status=${job.status})` : "";
|
||||
emitBare(`Cancelled ${jobId}${status}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listCheckpoints,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const CHECKPOINTS_FLAGS = {
|
||||
jobId: {
|
||||
@@ -47,7 +47,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,5 +60,6 @@ export default defineCommand({
|
||||
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "bailian-cli-core";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { basename } from "path";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* A `--datasets` / `--validations` token is treated as a local file to upload
|
||||
@@ -631,6 +631,7 @@ async function runCreate<F extends FlagsDef>(
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
jobId: {
|
||||
@@ -36,6 +36,7 @@ export default defineCommand({
|
||||
emitBare(jobId);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${jobId}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
exportCheckpoint,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const EXPORT_FLAGS = {
|
||||
jobId: {
|
||||
@@ -69,6 +69,7 @@ export default defineCommand({
|
||||
emitBare(
|
||||
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
|
||||
);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
jobId: {
|
||||
@@ -56,7 +56,7 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
emitResult({ ...item, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,5 +76,6 @@ export default defineCommand({
|
||||
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -48,7 +48,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,5 +78,6 @@ export default defineCommand({
|
||||
emitBare(
|
||||
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
|
||||
);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type FineTuneLogEntry,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* Render a single log entry as a single line (mirrors the flatten logic used
|
||||
@@ -187,6 +187,7 @@ export default defineCommand({
|
||||
emitBare(renderEntry(entry));
|
||||
}
|
||||
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = 10;
|
||||
const MIN_INTERVAL_SEC = 1;
|
||||
@@ -135,9 +135,13 @@ export default defineCommand({
|
||||
} else if (format === "text") {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
// json: a compact, purpose-built status probe.
|
||||
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
|
||||
emitResult(
|
||||
{ job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id },
|
||||
format,
|
||||
);
|
||||
}
|
||||
|
||||
if (terminal && status !== "SUCCEEDED") {
|
||||
@@ -175,6 +179,7 @@ export default defineCommand({
|
||||
emitResult(response, format);
|
||||
} else if (status === "SUCCEEDED") {
|
||||
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
}
|
||||
if (status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
|
||||
@@ -47,7 +47,7 @@ const EDIT_FLAGS = {
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Model ID (default: qwen-image-2.0)",
|
||||
description: "Model ID (default: qwen-image-3.0)",
|
||||
},
|
||||
size: {
|
||||
type: "string",
|
||||
@@ -123,7 +123,7 @@ export default defineCommand({
|
||||
}
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
|
||||
const route = resolveImageEditApi(model);
|
||||
|
||||
// Auto-upload local files (resolve all images in parallel)
|
||||
|
||||
@@ -35,7 +35,7 @@ const GENERATE_FLAGS = {
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Model ID (default: qwen-image-2.0)",
|
||||
description: "Model ID (default: qwen-image-3.0)",
|
||||
},
|
||||
size: {
|
||||
type: "string",
|
||||
@@ -105,7 +105,7 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
|
||||
const route = resolveImageGenerateApi(model);
|
||||
const defaultSize = "1:1";
|
||||
const sizeInput = flags.size || defaultSize;
|
||||
|
||||
@@ -25,7 +25,7 @@ const PROVIDER_BLOCKS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const SINGLE_MODEL: Record<string, string> = {
|
||||
bailian: ` model: qwen3.7-max`,
|
||||
bailian: ` model: qwen3.8-max`,
|
||||
claude: ` model: claude-sonnet-4-6`,
|
||||
qoder: ` model: ultimate`,
|
||||
ark: ` model: doubao-seed-2-1-pro-260628`,
|
||||
@@ -39,7 +39,7 @@ function buildTemplate(options: { provider: string; agentName: string }): string
|
||||
|
||||
const modelBlock =
|
||||
options.provider === "all"
|
||||
? ` model:\n bailian: qwen3.7-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628`
|
||||
? ` model:\n bailian: qwen3.8-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628`
|
||||
: SINGLE_MODEL[options.provider]!;
|
||||
|
||||
const toolBlock =
|
||||
|
||||
@@ -22,11 +22,15 @@ export function listenLocalServer(server: http.Server, port = 0): Promise<number
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
/**
|
||||
* Open a local file, directory, or URL with the OS default handler
|
||||
* (best-effort, cross-platform). Arguments are passed to `execFile` as an array
|
||||
* so the target is never interpreted by a shell.
|
||||
*/
|
||||
export function openPath(target: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
const args = platform === "win32" ? ["/c", "start", "", target] : [target];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
@@ -35,3 +39,8 @@ export function openInBrowser(url: string): Promise<void> {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
return openPath(url);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
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: "--all | --name <name,...>",
|
||||
flags: {
|
||||
all: {
|
||||
type: "switch",
|
||||
description: "Install all skills from the registry",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<name,...>",
|
||||
description: "Comma-separated skill names to install",
|
||||
},
|
||||
},
|
||||
validate(flags) {
|
||||
if (flags.all && flags.name) return "Use either --all or --name, not both";
|
||||
if (!flags.all && !flags.name)
|
||||
return "Specify --all to install everything or --name <name,...> for specific skills";
|
||||
return undefined;
|
||||
},
|
||||
exampleArgs: ["--all", "--name spark-video,bailian-model-recommend"],
|
||||
async run(ctx) {
|
||||
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
|
||||
const index = await fetchSkillsIndex();
|
||||
const remoteNames = Object.keys(index.skills);
|
||||
const parsed = ctx.flags.all ? "all" : parseSkillNames(ctx.flags.name, false);
|
||||
const names = parsed === "all" ? remoteNames : parsed;
|
||||
|
||||
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,107 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectInstalledAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkillWithFanout,
|
||||
readSkillLock,
|
||||
runWithConcurrency,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
interface InitOutcome {
|
||||
name: string;
|
||||
status: "installed" | "failed";
|
||||
publishedAt?: string;
|
||||
agents?: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Prefix used to identify first-party Bailian skills in the registry. */
|
||||
const BAILIAN_PREFIX = "bailian-";
|
||||
|
||||
/** Max number of skills downloading/installing at the same time. */
|
||||
const INIT_CONCURRENCY = 3;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Install all bailian-* skills (one-shot bootstrap for new environments)",
|
||||
auth: "none",
|
||||
usageArgs: "",
|
||||
exampleArgs: [""],
|
||||
notes: [
|
||||
"Fetches the registry index and installs every skill whose name starts with bailian-",
|
||||
"Equivalent to: bl skill add --all (filtered to bailian-* skills)",
|
||||
],
|
||||
async run(ctx) {
|
||||
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
|
||||
const index = await fetchSkillsIndex();
|
||||
|
||||
// Discover all bailian-* skills from the live registry index
|
||||
const names = Object.keys(index.skills).filter((name) => name.startsWith(BAILIAN_PREFIX));
|
||||
|
||||
const lock = readSkillLock();
|
||||
const agents = detectInstalledAgents();
|
||||
|
||||
const tasks = names.map((name) => async (): Promise<InitOutcome> => {
|
||||
const entry = index.skills[name];
|
||||
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, INIT_CONCURRENCY);
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
registry: getSkillRegistryBaseUrl(),
|
||||
agents: agents.map((agent) => agent.id),
|
||||
skills: results,
|
||||
},
|
||||
format,
|
||||
);
|
||||
} else if (results.length === 0) {
|
||||
emitBare("No bailian-* skills found in the registry.");
|
||||
} 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 init",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
defineCommand,
|
||||
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 = ctx.settings.outputExplicit ? ctx.settings.output : "json";
|
||||
// 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,99 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
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 = ctx.settings.outputExplicit ? ctx.settings.output : "json";
|
||||
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,158 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
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: "[--all] [--name <name,...>]",
|
||||
flags: {
|
||||
all: {
|
||||
type: "switch",
|
||||
description: "Update all installed skills (default when neither --all nor --name is given)",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<name,...>",
|
||||
description: "Comma-separated skill names to update (must be already installed)",
|
||||
},
|
||||
},
|
||||
validate(flags) {
|
||||
if (flags.all && flags.name) return "Use either --all or --name, not both";
|
||||
return undefined;
|
||||
},
|
||||
exampleArgs: ["", "--all", "--name spark-video"],
|
||||
async run(ctx) {
|
||||
const format = ctx.settings.outputExplicit ? ctx.settings.output : "json";
|
||||
const updateAll = ctx.flags.all || !ctx.flags.name;
|
||||
const requested = updateAll ? "all" : parseSkillNames(ctx.flags.name, false);
|
||||
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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -113,3 +113,8 @@ 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";
|
||||
export { default as skillInit } from "./commands/skill/init.ts";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
AGENT_COMMANDS,
|
||||
agentCommand,
|
||||
agentLaunchable,
|
||||
launchAgent,
|
||||
} from "../src/commands/config/agent-launch.ts";
|
||||
|
||||
test("agentCommand 返回已知 agent 的可执行命令,未知返回 undefined", () => {
|
||||
expect(agentCommand("qwen-code")).toBe("qwen");
|
||||
expect(agentCommand("codex")).toBe("codex");
|
||||
expect(agentCommand("nope")).toBeUndefined();
|
||||
// Guards against prototype keys leaking through the allowlist lookup.
|
||||
expect(agentCommand("toString")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("AGENT_COMMANDS 覆盖所有已知 agent id", () => {
|
||||
expect(Object.keys(AGENT_COMMANDS).sort()).toEqual(
|
||||
["claude-code", "codex", "hermes", "opencode", "openclaw", "qwen-code"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("launchAgent 对未知 id 抛错且不启动任何进程", async () => {
|
||||
await expect(launchAgent("definitely-not-an-agent")).rejects.toThrow(/Unknown agent/);
|
||||
});
|
||||
|
||||
test("agentLaunchable 对未知 id 返回 false,不探测 PATH", async () => {
|
||||
expect(await agentLaunchable("definitely-not-an-agent")).toBe(false);
|
||||
// Prototype keys must not resolve to a launchable command either.
|
||||
expect(await agentLaunchable("toString")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, sep } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { listAssets, resolveAssetPath, contentType } from "../src/commands/config/assets.ts";
|
||||
|
||||
/** Build an isolated temp output base and clean it up afterwards. */
|
||||
function withBase(fn: (base: string) => void): void {
|
||||
const base = mkdtempSync(join(tmpdir(), "bl-assets-"));
|
||||
try {
|
||||
fn(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function put(base: string, rel: string, content = "x"): string {
|
||||
const path = join(base, rel);
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
test("listAssets 按分类归类并识别类型", () => {
|
||||
withBase((base) => {
|
||||
put(base, "images/a.png");
|
||||
put(base, "videos/clip.mp4");
|
||||
put(base, "speech/voice.mp3");
|
||||
put(base, "notes.txt"); // loose file -> other
|
||||
|
||||
const { base: reported, assets } = listAssets(base);
|
||||
expect(reported).toBe(base);
|
||||
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
|
||||
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image", ext: "png" });
|
||||
expect(byName["clip.mp4"]).toMatchObject({ category: "videos", kind: "video", ext: "mp4" });
|
||||
expect(byName["voice.mp3"]).toMatchObject({ category: "speech", kind: "audio", ext: "mp3" });
|
||||
expect(byName["notes.txt"]).toMatchObject({ category: "other", kind: "other" });
|
||||
});
|
||||
});
|
||||
|
||||
test("listAssets 递归扫描任意子文件夹(非预设分类目录)", () => {
|
||||
withBase((base) => {
|
||||
put(base, "news-articles/report.md");
|
||||
put(base, "custom/deep/nested/pic.png");
|
||||
put(base, "images/a.png");
|
||||
|
||||
const { assets } = listAssets(base);
|
||||
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
|
||||
// Arbitrary top-level folder becomes the category.
|
||||
expect(byName["report.md"]).toMatchObject({
|
||||
category: "news-articles",
|
||||
kind: "other",
|
||||
ext: "md",
|
||||
});
|
||||
// Deeply nested file is discovered; category is its top-level folder.
|
||||
expect(byName["pic.png"]).toMatchObject({ category: "custom", kind: "image" });
|
||||
expect(byName["pic.png"]!.relPath).toBe(join("custom", "deep", "nested", "pic.png"));
|
||||
// Known category dirs still work.
|
||||
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image" });
|
||||
});
|
||||
});
|
||||
|
||||
test("listAssets 按生成时间倒序排列", () => {
|
||||
withBase((base) => {
|
||||
const older = put(base, "images/old.png");
|
||||
const newer = put(base, "images/new.png");
|
||||
// Force a stable ordering by stamping mtimes.
|
||||
utimesSync(older, new Date(1000), new Date(1000));
|
||||
utimesSync(newer, new Date(2000), new Date(2000));
|
||||
|
||||
const { assets } = listAssets(base);
|
||||
expect(assets.map((a) => a.name)).toEqual(["new.png", "old.png"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listAssets 目录不存在时返回空", () => {
|
||||
const { assets } = listAssets(join(tmpdir(), "bl-assets-does-not-exist-xyz"));
|
||||
expect(assets).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolveAssetPath 阻止目录穿越", () => {
|
||||
withBase((base) => {
|
||||
put(base, "images/a.png");
|
||||
expect(resolveAssetPath(base, "images/a.png")).toBe(join(base, "images/a.png"));
|
||||
expect(resolveAssetPath(base, "../../etc/passwd")).toBeNull();
|
||||
expect(resolveAssetPath(base, "")).toBeNull();
|
||||
expect(resolveAssetPath(base, "images" + sep + ".." + sep + ".." + sep + "outside")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("contentType 映射常见扩展名", () => {
|
||||
expect(contentType(".png")).toBe("image/png");
|
||||
expect(contentType(".MP4")).toBe("video/mp4");
|
||||
expect(contentType(".mp3")).toBe("audio/mpeg");
|
||||
expect(contentType(".xyz")).toBe("application/octet-stream");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { makeAuthUiBridge } from "../src/commands/auth/console-ui.ts";
|
||||
import type { AuthState, AuthStore, Identity, Settings } from "bailian-cli-core";
|
||||
|
||||
/** Build a bridge over a fake AuthStore. Only describe()/logout() are used by
|
||||
* the surface under test; startConsoleLogin() is intentionally not exercised
|
||||
* (it opens a browser and starts a real callback server). */
|
||||
function bridgeWith(state: AuthState, onLogout?: (scope: string) => Promise<boolean>) {
|
||||
const authStore = {
|
||||
describe: () => state,
|
||||
stored: () => ({ apiKey: false, console: false, openapi: false }),
|
||||
resolveBaseUrl: () => "https://dashscope.aliyuncs.com",
|
||||
login: async () => {},
|
||||
logout: onLogout ?? (async () => false),
|
||||
path: "/tmp/config.json",
|
||||
} as unknown as AuthStore;
|
||||
return makeAuthUiBridge({
|
||||
identity: {} as unknown as Identity,
|
||||
settings: {} as unknown as Settings,
|
||||
authStore,
|
||||
});
|
||||
}
|
||||
|
||||
test("status: console 凭证 -> primary=console,带 region/site 与掩码 token", () => {
|
||||
const st = bridgeWith({
|
||||
console: {
|
||||
token: "abcd1234efgh5678",
|
||||
region: "cn-beijing",
|
||||
site: "domestic",
|
||||
source: "config",
|
||||
},
|
||||
}).status();
|
||||
expect(st.authenticated).toBe(true);
|
||||
expect(st.primary).toBe("console");
|
||||
expect(st.methods).toEqual({ apiKey: false, console: true, openapi: false });
|
||||
expect(st.region).toBe("cn-beijing");
|
||||
expect(st.site).toBe("domestic");
|
||||
expect(st.masked).toContain("...");
|
||||
// Masked, never the raw token.
|
||||
expect(st.masked).not.toBe("abcd1234efgh5678");
|
||||
});
|
||||
|
||||
test("status: 无任何凭证 -> 未认证,无 masked", () => {
|
||||
const st = bridgeWith({}).status();
|
||||
expect(st.authenticated).toBe(false);
|
||||
expect(st.primary).toBe(null);
|
||||
expect(st.masked).toBeUndefined();
|
||||
expect(st.methods).toEqual({ apiKey: false, console: false, openapi: false });
|
||||
});
|
||||
|
||||
test("status: 仅 apiKey -> primary=apiKey", () => {
|
||||
const st = bridgeWith({
|
||||
apiKey: { token: "sk-1234567890", baseUrl: "https://dashscope.aliyuncs.com", source: "env" },
|
||||
}).status();
|
||||
expect(st.primary).toBe("apiKey");
|
||||
expect(st.methods.apiKey).toBe(true);
|
||||
expect(st.region).toBeUndefined();
|
||||
});
|
||||
|
||||
test("logout 委托给 authStore.logout('all')", async () => {
|
||||
let scope = "";
|
||||
const bridge = bridgeWith(
|
||||
{ apiKey: { token: "sk-x", baseUrl: "https://x", source: "config" } },
|
||||
async (s) => {
|
||||
scope = s;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
expect(await bridge.logout()).toBe(true);
|
||||
expect(scope).toBe("all");
|
||||
});
|
||||
@@ -83,6 +83,43 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
|
||||
expect(res.json.secretKeys).toContain("api_key");
|
||||
expect(res.json.keys).toContain("default_image_to_video_model");
|
||||
expect(res.json.keys).toContain("default_reference_to_video_model");
|
||||
// Console/telemetry fields are editable via the UI (full ConfigFile surface).
|
||||
expect(res.json.keys).toContain("console_site");
|
||||
expect(res.json.keys).toContain("telemetry");
|
||||
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
|
||||
expect(res.json.booleanKeys).toContain("telemetry");
|
||||
// Default field hints are surfaced as prefilled values in the UI.
|
||||
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
|
||||
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
|
||||
expect(res.json.fieldDefaults.output_dir).toContain("bailian-output");
|
||||
expect(res.json.fieldDefaults.timeout).toBe("300");
|
||||
expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com");
|
||||
// Per-category model catalog (click-to-fill suggestions) is exposed too.
|
||||
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-3.0" });
|
||||
expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain(
|
||||
"happyhorse-1.1-i2v",
|
||||
);
|
||||
expect(res.json.modelCatalog.default_speech_model.map((m: { id: string }) => m.id)).toContain(
|
||||
"fun-asr",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /api/auth/status 无 bridge 时返回未认证;login/logout 返回 400", async () => {
|
||||
await withServer(async (port) => {
|
||||
// The test harness builds the server without an auth bridge, so the auth
|
||||
// endpoints degrade safely instead of throwing.
|
||||
const status = await httpJson(port, "GET", `/api/auth/status?token=${TOKEN}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.json.authenticated).toBe(false);
|
||||
expect(status.json.methods).toEqual({ apiKey: false, console: false, openapi: false });
|
||||
expect(status.json.primary).toBe(null);
|
||||
|
||||
const login = await httpJson(port, "POST", `/api/auth/login?token=${TOKEN}`);
|
||||
expect(login.status).toBe(400);
|
||||
|
||||
const logout = await httpJson(port, "POST", `/api/auth/logout?token=${TOKEN}`);
|
||||
expect(logout.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,44 +167,42 @@ test("POST /api/profile 写命名 profile(timeout 强制为 number),空串
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
|
||||
test("POST /api/profile 可编辑 console/telemetry 字段并按类型持久化", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile(
|
||||
{
|
||||
api_key: "sk-old",
|
||||
output: "json",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
},
|
||||
"stage",
|
||||
);
|
||||
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "sk-new" } },
|
||||
body: {
|
||||
name: "stage",
|
||||
data: {
|
||||
api_key: "sk-stage",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: "42",
|
||||
telemetry: "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
|
||||
const profile = readConfigFile("stage");
|
||||
expect(profile).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
api_key: "sk-stage",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
});
|
||||
expect(profile.output).toBeUndefined();
|
||||
|
||||
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
|
||||
expect(rawConfig.stage).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
// Coerced to the right JSON types, not left as strings.
|
||||
expect(rawConfig.stage.console_switch_agent).toBe(42);
|
||||
expect(rawConfig.stage.telemetry).toBe(false);
|
||||
|
||||
// Invalid enum value is rejected.
|
||||
const bad = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { console_site: "mars" } },
|
||||
});
|
||||
expect(rawConfig.stage.output).toBeUndefined();
|
||||
expect(bad.status).toBe(400);
|
||||
expect(String(bad.json.error)).toMatch(/console_site/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("e2e: auth", () => {
|
||||
authorization: "Bearer sk-e2e-placeholder",
|
||||
sourceConfig: expect.any(String),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
model: "qwen3.8-max",
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
@@ -266,7 +266,7 @@ describe("e2e: auth", () => {
|
||||
expect(config["token-plan"]).toMatchObject({
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: validationServer.baseUrl,
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
@@ -314,7 +314,7 @@ describe("e2e: auth", () => {
|
||||
authorization: "Bearer sk-sp-e2e-placeholder",
|
||||
sourceConfig: expect.any(String),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
model: "qwen3.8-max",
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
@@ -328,7 +328,7 @@ describe("e2e: auth", () => {
|
||||
expect(config["token-plan"]).toMatchObject({
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
|
||||
@@ -21,7 +21,7 @@ agents:
|
||||
assistant:
|
||||
description: "E2E multi-provider fixture"
|
||||
model:
|
||||
bailian: qwen3.7-max
|
||||
bailian: qwen3.8-max
|
||||
claude: claude-sonnet-4-6
|
||||
instructions: |
|
||||
You are a helpful assistant.
|
||||
|
||||
@@ -194,13 +194,13 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
|
||||
expect(stderr).toMatch(/--prompt|Usage:/i);
|
||||
});
|
||||
|
||||
test("【qwen-image-2.0】图片编辑", async () => {
|
||||
test("【qwen-image-3.0】图片编辑", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const gen = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-3.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
@@ -220,7 +220,7 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: ima
|
||||
"image",
|
||||
"edit",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-3.0",
|
||||
"--image",
|
||||
imagePath!,
|
||||
"--prompt",
|
||||
|
||||
@@ -202,19 +202,19 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-3.0",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--prompt|Usage:/i);
|
||||
});
|
||||
|
||||
test("【qwen-image-2.0】图片生成", async () => {
|
||||
test("【qwen-image-3.0】图片生成", async () => {
|
||||
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-3.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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(/--all/);
|
||||
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(/--all/);
|
||||
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);
|
||||
});
|
||||
|
||||
test("skill init --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "init", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/bailian/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 --all or --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(/--all|--name|Usage:/i);
|
||||
});
|
||||
|
||||
test("skill add with both --all and --name errors as usage error (2)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
|
||||
"skill",
|
||||
"add",
|
||||
"--all",
|
||||
"--name",
|
||||
"spark-video",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--all|--name|either/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 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 chat(DashScope)", () => {
|
||||
"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 chat(DashScope)", () => {
|
||||
"chat",
|
||||
"--dry-run",
|
||||
"--model",
|
||||
"qwen3.7-max",
|
||||
"qwen3.8-max",
|
||||
"--message",
|
||||
"干跑",
|
||||
"--max-tokens",
|
||||
@@ -44,16 +44,16 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
const data = parseStdoutJson<{
|
||||
request?: { model?: string; messages?: Array<{ content?: string }> };
|
||||
}>(stdout);
|
||||
expect(data.request?.model).toBe("qwen3.7-max");
|
||||
expect(data.request?.model).toBe("qwen3.8-max");
|
||||
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
|
||||
});
|
||||
|
||||
test("【qwen3.7-max】文本对话", async () => {
|
||||
test("【qwen3.8-max】文本对话", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
|
||||
"text",
|
||||
"chat",
|
||||
"--model",
|
||||
"qwen3.7-max",
|
||||
"qwen3.8-max",
|
||||
"--message",
|
||||
"只回复一个字:好",
|
||||
"--max-tokens",
|
||||
|
||||
@@ -10,6 +10,10 @@ export const AUTH_ROUTES: E2eRouteExports = {
|
||||
"auth logout": "authLogout",
|
||||
};
|
||||
|
||||
export const UPDATE_ROUTES: E2eRouteExports = {
|
||||
update: "update",
|
||||
};
|
||||
|
||||
export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
|
||||
|
||||
export const CONFIG_ROUTES: E2eRouteExports = {
|
||||
@@ -156,6 +160,14 @@ 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",
|
||||
"skill init": "skillInit",
|
||||
};
|
||||
|
||||
export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
|
||||
"managed-agent init": "managedAgentInit",
|
||||
"managed-agent validate": "managedAgentValidate",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-3.0",
|
||||
"--prompt",
|
||||
"一只简笔画小猫,白底",
|
||||
"--out-dir",
|
||||
|
||||
@@ -169,7 +169,7 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-3.0",
|
||||
"--prompt",
|
||||
"一片绿色的树叶,白底",
|
||||
"--out-dir",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
listSkills,
|
||||
listMcpServers,
|
||||
listAgents,
|
||||
getSkillDetail,
|
||||
} from "../src/commands/config/inventory.ts";
|
||||
|
||||
/** Build an isolated fake $HOME and clean it up afterwards. */
|
||||
function withHome(fn: (home: string) => void): void {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-inv-"));
|
||||
try {
|
||||
fn(home);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function write(home: string, rel: string, content: string): void {
|
||||
const path = join(home, rel);
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
test("getSkillDetail 返回 SKILL.md 原文,未知 id 返回 null", () => {
|
||||
withHome((home) => {
|
||||
write(
|
||||
home,
|
||||
".agents/skills/demo/SKILL.md",
|
||||
"---\nname: demo-skill\ndescription: A demo skill.\n---\n# Body\nhello world\n",
|
||||
);
|
||||
|
||||
const detail = getSkillDetail("demo", home);
|
||||
expect(detail).not.toBeNull();
|
||||
expect(detail?.name).toBe("demo-skill");
|
||||
expect(detail?.content).toContain("hello world");
|
||||
expect(getSkillDetail("nope", home)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 解析 SKILL.md frontmatter 与文件数", () => {
|
||||
withHome((home) => {
|
||||
write(
|
||||
home,
|
||||
".agents/skills/demo/SKILL.md",
|
||||
'---\nname: demo-skill\nmetadata:\n version: "2.1.0"\ndescription: A demo skill.\n---\n# Body\n',
|
||||
);
|
||||
write(home, ".agents/skills/demo/assets/a.md", "x");
|
||||
// Directory without SKILL.md is ignored.
|
||||
mkdirSync(join(home, ".agents/skills/not-a-skill"), { recursive: true });
|
||||
|
||||
const skills = listSkills(home);
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0]).toMatchObject({
|
||||
id: "demo",
|
||||
name: "demo-skill",
|
||||
version: "2.1.0",
|
||||
description: "A demo skill.",
|
||||
sources: ["global"],
|
||||
origin: "local",
|
||||
});
|
||||
expect(skills[0].fileCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 将 agent 目录下的软链接视为安装来源", () => {
|
||||
withHome((home) => {
|
||||
write(home, ".agents/skills/bailian-cli/SKILL.md", "---\nname: bailian-cli\n---\n");
|
||||
// Mimic `skills add`: the agent copy is a symlink back to the global dir.
|
||||
mkdirSync(join(home, ".claude/skills"), { recursive: true });
|
||||
symlinkSync(join(home, ".agents/skills/bailian-cli"), join(home, ".claude/skills/bailian-cli"));
|
||||
|
||||
const skills = listSkills(home);
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].sources).toEqual(["global", "claude-code"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 跨 agent 模块聚合并记录来源", () => {
|
||||
withHome((home) => {
|
||||
// Same skill installed in the global dir and two agent modules.
|
||||
const skillMd = "---\nname: bailian-cli\n---\n# B\n";
|
||||
write(home, ".agents/skills/bailian-cli/SKILL.md", skillMd);
|
||||
write(home, ".claude/skills/bailian-cli/SKILL.md", skillMd);
|
||||
write(home, ".qwen/skills/bailian-cli/SKILL.md", skillMd);
|
||||
// A skill only present in qwen.
|
||||
write(home, ".qwen/skills/spark-video/SKILL.md", "---\nname: spark-video\n---\n");
|
||||
|
||||
const skills = listSkills(home);
|
||||
const byId = Object.fromEntries(skills.map((s) => [s.id, s]));
|
||||
expect(byId["bailian-cli"].sources).toEqual(["global", "claude-code", "qwen-code"]);
|
||||
expect(byId["spark-video"].sources).toEqual(["qwen-code"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 目录缺失时返回空数组", () => {
|
||||
withHome((home) => {
|
||||
expect(listSkills(home)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listMcpServers 汇总 codex(toml) 与 claude(json) 的 MCP 定义", () => {
|
||||
withHome((home) => {
|
||||
write(home, ".codex/config.toml", '[mcp_servers.repl]\ncommand = "node"\nargs = ["repl.js"]\n');
|
||||
write(
|
||||
home,
|
||||
".claude.json",
|
||||
JSON.stringify({
|
||||
mcpServers: { web: { url: "https://example.com/mcp", type: "sse" } },
|
||||
projects: {
|
||||
"/proj": {
|
||||
mcpServers: { local: { command: "python", args: ["s.py"] } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const servers = listMcpServers(home);
|
||||
const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
|
||||
expect(byName.repl).toMatchObject({
|
||||
source: "codex",
|
||||
transport: "stdio",
|
||||
origin: "local",
|
||||
});
|
||||
expect(byName.repl.detail).toContain("node repl.js");
|
||||
expect(byName.web).toMatchObject({
|
||||
source: "claude-code",
|
||||
transport: "sse",
|
||||
scope: "global",
|
||||
});
|
||||
expect(byName.local).toMatchObject({
|
||||
source: "claude-code",
|
||||
transport: "stdio",
|
||||
scope: "/proj",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("listMcpServers 无配置时返回空数组", () => {
|
||||
withHome((home) => {
|
||||
expect(listMcpServers(home)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listAgents 报告安装与已连接 bailian-cli 的状态", () => {
|
||||
withHome((home) => {
|
||||
// Claude Code: installed + configured (base url present).
|
||||
write(
|
||||
home,
|
||||
".claude/settings.json",
|
||||
JSON.stringify({
|
||||
env: { ANTHROPIC_BASE_URL: "https://x", ANTHROPIC_MODEL: "qwen3-max" },
|
||||
}),
|
||||
);
|
||||
// Codex: installed but NOT configured (no bailian-cli provider).
|
||||
write(home, ".codex/config.toml", 'model = "gpt-5"\n');
|
||||
// Qwen Code: configured via the new "[Bailian] <model>" display name.
|
||||
write(
|
||||
home,
|
||||
".qwen/settings.json",
|
||||
JSON.stringify({
|
||||
modelProviders: {
|
||||
openai: [{ id: "qwen3-coder-plus", name: "[Bailian] qwen3-coder-plus" }],
|
||||
},
|
||||
model: { name: "qwen3-coder-plus" },
|
||||
}),
|
||||
);
|
||||
// Hermes: configured via the official flat model block (no custom_providers).
|
||||
write(
|
||||
home,
|
||||
".hermes/config.yaml",
|
||||
[
|
||||
"model:",
|
||||
" default: qwen3-max",
|
||||
" provider: custom",
|
||||
" base_url: https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
" api_key: sk-test",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const agents = listAgents(home);
|
||||
const byId = Object.fromEntries(agents.map((a) => [a.id, a]));
|
||||
|
||||
expect(byId["claude-code"]).toMatchObject({
|
||||
installed: true,
|
||||
configured: true,
|
||||
model: "qwen3-max",
|
||||
origin: "local",
|
||||
});
|
||||
expect(byId.codex).toMatchObject({
|
||||
installed: true,
|
||||
configured: false,
|
||||
model: "gpt-5",
|
||||
});
|
||||
expect(byId["qwen-code"]).toMatchObject({
|
||||
installed: true,
|
||||
configured: true,
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
expect(byId.hermes).toMatchObject({
|
||||
installed: true,
|
||||
configured: true,
|
||||
model: "qwen3-max",
|
||||
});
|
||||
expect(byId.opencode).toMatchObject({
|
||||
installed: false,
|
||||
configured: false,
|
||||
});
|
||||
// Always reports all six known frameworks.
|
||||
expect(agents).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { rsGeneratorExp, rsEncode, qrMatrix, qrSvg } from "../src/commands/config/qr.ts";
|
||||
|
||||
test("rsGeneratorExp 匹配已知 QR 生成多项式(alpha 指数)", () => {
|
||||
// Well-documented QR generator polynomials — a strong correctness anchor for
|
||||
// the GF(256) arithmetic and polynomial construction.
|
||||
expect(rsGeneratorExp(7)).toEqual([0, 87, 229, 146, 149, 238, 102, 21]);
|
||||
expect(rsGeneratorExp(10)).toEqual([0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45]);
|
||||
expect(rsGeneratorExp(15)).toEqual([
|
||||
0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105,
|
||||
]);
|
||||
});
|
||||
|
||||
test("rsEncode 产出请求数量的纠错码字", () => {
|
||||
const ec = rsEncode([0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26], 10);
|
||||
expect(ec).toHaveLength(10);
|
||||
expect(ec.every((b) => b >= 0 && b <= 255)).toBe(true);
|
||||
});
|
||||
|
||||
test("qrMatrix 尺寸随版本增长且含三个定位图案", () => {
|
||||
const m = qrMatrix("http://127.0.0.1:8787/?token=" + "a".repeat(32));
|
||||
// ~61 byte URL -> version 4 (33x33).
|
||||
expect(m.length).toBe(33);
|
||||
expect(m[0]).toHaveLength(33);
|
||||
const size = m.length;
|
||||
// Finder pattern centers are dark (3x3 core) at the three corners.
|
||||
expect(m[3][3]).toBe(true);
|
||||
expect(m[3][size - 4]).toBe(true);
|
||||
expect(m[size - 4][3]).toBe(true);
|
||||
// Timing pattern alternates on row/col 6.
|
||||
expect(m[6][8]).toBe(true);
|
||||
expect(m[6][9]).toBe(false);
|
||||
});
|
||||
|
||||
test("qrMatrix 短文本用最小版本(V1=21x21)", () => {
|
||||
expect(qrMatrix("hi").length).toBe(21);
|
||||
});
|
||||
|
||||
test("qrSvg 返回带 viewBox 的 SVG 且含模块矩形", () => {
|
||||
const svg = qrSvg("http://127.0.0.1:8787/");
|
||||
expect(svg.startsWith("<svg")).toBe(true);
|
||||
expect(svg).toContain("viewBox=");
|
||||
expect(svg).toContain("<rect");
|
||||
});
|
||||
|
||||
test("qrMatrix 超长数据抛出清晰错误", () => {
|
||||
expect(() => qrMatrix("x".repeat(200))).toThrow(/too large/);
|
||||
});
|
||||
|
||||
// --- End-to-end: decode the produced matrix with the STANDARD reading order
|
||||
// and confirm it round-trips back to the original text. This is the real proof
|
||||
// that the code is scannable (format-info placement + mask + data placement),
|
||||
// which cannot be checked by structure alone. The format info is read from the
|
||||
// FIRST copy (top-left) using the canonical mapping, independent of the encoder.
|
||||
const MASK_FNS: Array<(r: number, c: number) => boolean> = [
|
||||
(r, c) => (r + c) % 2 === 0,
|
||||
(r) => r % 2 === 0,
|
||||
(_r, c) => c % 3 === 0,
|
||||
(r, c) => (r + c) % 3 === 0,
|
||||
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
||||
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
||||
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
];
|
||||
|
||||
function qrDecode(m: boolean[][]): { text: string; mask: number; ecLevel: number } {
|
||||
const size = m.length;
|
||||
const version = (size - 17) / 4;
|
||||
const bAt = (r: number, c: number) => (m[r][c] ? 1 : 0);
|
||||
|
||||
// Read the 15-bit format info from the first copy (canonical cell mapping).
|
||||
const cells: Array<[number, number]> = [
|
||||
[0, 8],
|
||||
[1, 8],
|
||||
[2, 8],
|
||||
[3, 8],
|
||||
[4, 8],
|
||||
[5, 8], // bits 0–5
|
||||
[7, 8], // bit 6
|
||||
[8, 8], // bit 7
|
||||
[8, 7], // bit 8
|
||||
[8, 5],
|
||||
[8, 4],
|
||||
[8, 3],
|
||||
[8, 2],
|
||||
[8, 1],
|
||||
[8, 0], // bits 9–14
|
||||
];
|
||||
let fmt = 0;
|
||||
for (let i = 0; i < 15; i++) fmt |= bAt(cells[i][0], cells[i][1]) << i;
|
||||
fmt ^= 0x5412;
|
||||
const mask = (fmt >> 10) & 7;
|
||||
const ecLevel = (fmt >> 13) & 3;
|
||||
const cond = MASK_FNS[mask];
|
||||
|
||||
// Independent function/reserved-module map.
|
||||
const isFn = (r: number, c: number): boolean => {
|
||||
if (r <= 7 && c <= 7) return true; // top-left finder + separator
|
||||
if (r <= 7 && c >= size - 8) return true; // top-right finder
|
||||
if (r >= size - 8 && c <= 7) return true; // bottom-left finder
|
||||
if (r === 6 || c === 6) return true; // timing
|
||||
if (r === 8 && (c <= 8 || c >= size - 8)) return true; // format (horizontal)
|
||||
if (c === 8 && (r <= 8 || r >= size - 8)) return true; // format (vertical) + dark
|
||||
if (version >= 2) {
|
||||
const ac = size - 7;
|
||||
if (Math.abs(r - ac) <= 2 && Math.abs(c - ac) <= 2) return true; // alignment
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Read data modules in the standard right-to-left zigzag, un-masking as we go.
|
||||
const bits: number[] = [];
|
||||
let upward = true;
|
||||
for (let col = size - 1; col >= 1; col -= 2) {
|
||||
if (col === 6) col = 5;
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = upward ? size - 1 - i : i;
|
||||
for (const off of [0, 1]) {
|
||||
const cc = col - off;
|
||||
if (isFn(row, cc)) continue;
|
||||
let b = bAt(row, cc);
|
||||
if (cond(row, cc)) b ^= 1;
|
||||
bits.push(b);
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
|
||||
let p = 0;
|
||||
const read = (n: number) => {
|
||||
let v = 0;
|
||||
for (let k = 0; k < n; k++) v = (v << 1) | (bits[p++] ?? 0);
|
||||
return v;
|
||||
};
|
||||
const mode = read(4);
|
||||
if (mode !== 0b0100) throw new Error("decode: not byte mode, got " + mode);
|
||||
const len = read(8);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < len; i++) out.push(read(8));
|
||||
return { text: new TextDecoder().decode(new Uint8Array(out)), mask, ecLevel };
|
||||
}
|
||||
|
||||
test("qrMatrix → 标准解码能无损还原原文(失败则说明无法扫描)", () => {
|
||||
const samples = [
|
||||
"hi",
|
||||
"http://127.0.0.1:8787/",
|
||||
"http://127.0.0.1:8787/?token=" + "a".repeat(32),
|
||||
"http://127.0.0.1:65535/?token=0123456789abcdef0123456789abcdef",
|
||||
];
|
||||
for (const text of samples) {
|
||||
const decoded = qrDecode(qrMatrix(text));
|
||||
expect(decoded.text).toBe(text);
|
||||
expect(decoded.ecLevel).toBe(0b01); // error-correction level L
|
||||
expect(decoded.mask).toBeGreaterThanOrEqual(0);
|
||||
expect(decoded.mask).toBeLessThanOrEqual(7);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.2",
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -62,7 +62,11 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore {
|
||||
const configName = sources.configName;
|
||||
const activateAfterLogin = sources.flags.config !== undefined;
|
||||
return {
|
||||
describe: () => describeAuthState(sources),
|
||||
// Read the config block live (not the startup `sources.file` snapshot) so
|
||||
// long-lived processes like `config ui` reflect a fresh login without a
|
||||
// restart. For one-shot commands this reads the same content the snapshot
|
||||
// held, so behavior is unchanged.
|
||||
describe: () => describeAuthState({ ...sources, file: readConfigFile(configName) }),
|
||||
stored() {
|
||||
const file = readConfigFile(configName);
|
||||
return {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { image2ImagePath, imagePath, imageSyncPath, imageText2ImagePath } from "
|
||||
* - async text2image + prompt: wan2.5/2.2/2.1-t2i*, wanx*-t2i*
|
||||
*
|
||||
* Edit (I2I):
|
||||
* - sync multimodal + messages(+images): qwen-image-2.0*, qwen-image-edit*, wan2.6-image*, wan2.7-image*
|
||||
* - sync multimodal + messages(+images): qwen-image-3.0*, qwen-image-2.0*, qwen-image-edit*, wan2.6-image*, wan2.7-image*
|
||||
* (pure T2I models such as z-image / qwen-image-plus / qwen-image-max are NOT edit models)
|
||||
* - async image2image + prompt/images: wan2.5-i2i*
|
||||
* - async image2image + function/base_image_url: *imageedit* (e.g. wanx2.1-imageedit)
|
||||
@@ -60,6 +60,7 @@ const SYNC_GENERATE_PREFIXES = ["qwen-image", "wan2.7-image", "z-image"] as cons
|
||||
* Pure T2I models (z-image / qwen-image-plus / qwen-image-max) are excluded.
|
||||
*/
|
||||
const SYNC_EDIT_PREFIXES = [
|
||||
"qwen-image-3.0",
|
||||
"qwen-image-2.0",
|
||||
"qwen-image-edit",
|
||||
"wan2.7-image",
|
||||
@@ -109,7 +110,12 @@ export function isWanxFunctionImageEditModel(model: string): boolean {
|
||||
}
|
||||
|
||||
export function resolveImageSizeProfile(model: string): ImageSizeProfile {
|
||||
if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-edit")) {
|
||||
// 3.0 暂复用 2.0 高分比例表(CLI ratio→像素便捷映射);不做独立 3.0 profile。
|
||||
if (
|
||||
model.startsWith("qwen-image-3.0") ||
|
||||
model.startsWith("qwen-image-2.0") ||
|
||||
model.startsWith("qwen-image-edit")
|
||||
) {
|
||||
return "qwen-image-2.0";
|
||||
}
|
||||
// Remaining qwen-image* (plus / max / bare qwen-image) share the fixed table.
|
||||
@@ -133,7 +139,13 @@ export function resolveImageSizeProfile(model: string): ImageSizeProfile {
|
||||
|
||||
/** Official / CLI defaults for prompt_extend when the flag is omitted. */
|
||||
export function resolvePromptExtendDefault(model: string): boolean | undefined {
|
||||
if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-max")) return true;
|
||||
if (
|
||||
model.startsWith("qwen-image-3.0") ||
|
||||
model.startsWith("qwen-image-2.0") ||
|
||||
model.startsWith("qwen-image-max")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Z-Image docs default prompt_extend to false.
|
||||
if (model.startsWith("z-image")) return false;
|
||||
return undefined;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user