mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 575110e62d | |||
| 882bc6becb | |||
| 78ca730100 | |||
| 539247b10d | |||
| 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 | |||
| afa43a42b9 | |||
| bbf45a5961 | |||
| 3988e701e1 | |||
| 96744e3328 | |||
| 3b7993e854 | |||
| be6ddb6126 | |||
| 25ac5c9c84 | |||
| 8ef91fe395 | |||
| 5d9e22de8f | |||
| 20e3555b84 | |||
| 81fa5b567c | |||
| df987ad536 | |||
| 4c4e7afb83 | |||
| 17c52fb86f | |||
| fb0c4b81be | |||
| 634d7045c6 | |||
| 952f2277a4 | |||
| 871c667e97 | |||
| eadd92327f | |||
| 4c494207d6 | |||
| af3286dd00 | |||
| 6465c4a78a | |||
| 467756b319 | |||
| 5a58f56b06 | |||
| 7ad14a79b9 | |||
| 8211268bd8 | |||
| 7319f6d1ce | |||
| 2dce9fe093 | |||
| 9819eb6ddc | |||
| 58252911a8 | |||
| a03ee0c72c | |||
| 05860b3bdd | |||
| 63ee5aaec3 | |||
| 93c9149e45 | |||
| 6f9e006fef | |||
| e22058b0f7 | |||
| 0221e35803 | |||
| 7250de9228 | |||
| 36ebd63716 | |||
| dac254af86 | |||
| 8a0fb870f1 | |||
| 51ed69596e | |||
| 67b7fa30a7 | |||
| bd17c27023 | |||
| 87c37994f2 | |||
| ff469ce717 | |||
| 5f0966ec8d | |||
| c4f5bb09c6 | |||
| 9a13700390 | |||
| 32c497db63 | |||
| ebbd173b79 | |||
| 6bdc16597b | |||
| e736bab9c1 | |||
| 8dd786287f | |||
| 247bb82154 | |||
| 1e6165d7ff | |||
| 1bf4fec9e6 | |||
| d30fb2ae68 | |||
| 1d589c5178 | |||
| 9cad1994e7 | |||
| fac2b2d18b | |||
| 3e249279bc | |||
| a1a448c5d2 | |||
| 7b949d3d3c | |||
| f9012a6330 | |||
| 92ee845bdd | |||
| 4751145283 | |||
| 168e2b5ccb | |||
| 9fbd2e4ec6 | |||
| 4bd84e934c | |||
| 08bdc3be97 | |||
| 66a797203c | |||
| 64335a6201 | |||
| 90a44d7140 | |||
| 26a69a7c99 | |||
| e1caee99f2 | |||
| 1da3367de8 | |||
| 9e59b01326 | |||
| 7cbd61dd5c | |||
| 1c9dac24e9 | |||
| d6bd38a46a | |||
| 10ddd0a948 | |||
| 9ab5de8c2e | |||
| 6329427b4d | |||
| 7b5af2c205 | |||
| d08edf0cd8 | |||
| 2a1c96fb43 | |||
| 788d3faafc | |||
| f32bd2323f |
@@ -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,30 +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) |
|
||||
| 发布 | 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`,把清单沉淀下来。
|
||||
|
||||
|
||||
+105
@@ -6,6 +6,111 @@ 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
|
||||
|
||||
- **`bl config agent --key` / `--region`** — run commands generated by the Model Studio web console as-is: `--key` accepts the console's encoded API key and decodes it locally (use instead of `--api-key`), and `--region` derives the Token Plan endpoint from a region name (use instead of `--base-url`).
|
||||
- **`bl config agent --context-window`** — set the context window written to the OpenClaw configuration (default 256000).
|
||||
- **`bl config agent --wire-api`** — choose the wire protocol written to the Codex configuration; `chat` is kept for legacy Codex 0.80.0 and earlier (a warning is shown).
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl config agent` for Codex now writes `wire_api = "responses"` by default, matching current Codex releases that no longer accept `chat`.
|
||||
- `bl config agent` for Qwen Code now writes the `DASHSCOPE_API_KEY` environment variable instead of `BAILIAN_CLI_API_KEY`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl config agent` configurations now match each agent's official format: Claude Code honors `CLAUDE_CONFIG_DIR` and removes a stale `ANTHROPIC_API_KEY`; Qwen Code uses the v3 settings schema and writes credentials so a system-level `OPENAI_API_KEY` no longer takes precedence; OpenCode accepts JSONC config files (comments and trailing commas); OpenClaw registers the primary model in the model allowlist with complete cost metadata; Hermes uses the official flat `model.*` layout; Codex writes the official `env_key` with an `auth.json` fallback.
|
||||
- `bl config agent` now preserves existing user configuration when writing: it merges instead of overwriting, avoids duplicate provider entries, and keeps custom display names.
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- MCP tools and WebSearch now provide activation guidance and direct marketplace links when Bailian reports that the corresponding service is not activated. WebSearch also guides users with legacy SSE connections to reactivate the service using Streamable HTTP.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed text chat and API Key validation compatibility failures caused by sending unsupported `enable_thinking` values. Text chat now sends the parameter only when thinking is explicitly enabled, while validation uses a compatible model without sending it.
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- `bl image edit` now supports `--function` for specifying edit operations with Wanx image-edit models such as `wanx2.1-imageedit`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed image generation and editing failures and incorrect size parameters for some image models, improving compatibility with Qwen-Image, Wan/Wanx, Z-Image, and dated `wanx-v1` variants.
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl managed-agent`** — declaratively manage Managed Agent infrastructure through a unified CLI. The Bailian provider connects to AgentStudio, with Claude, Qoder, and Ark providers also supported:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` — initialize and validate `agents.yaml`, preview and apply resource changes, and destroy managed resources.
|
||||
- `state list` / `state show` / `state rm` / `state import` — inspect and manage local resource state, including adopting an existing remote resource or removing it from local state without destroying it remotely.
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` — manage the full session lifecycle with streaming responses and structured `--output json` output.
|
||||
- `skill-list` — browse custom and official skills; use `--source all` to return both catalogs in one call.
|
||||
|
||||
### Changed
|
||||
|
||||
- Model Base URLs are now normalized to the URL origin; paths, query parameters, and fragments supplied in the Base URL are no longer included when constructing API request paths.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The installation guide no longer recommends the removed `--non-interactive` flag and now documents explicit required arguments, `--output json`, and `NO_COLOR=1` for non-interactive environments.
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
+105
@@ -6,6 +6,111 @@
|
||||
|
||||
[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
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent --key` / `--region`** —— 百炼控制台生成的命令可直接运行:`--key` 接收控制台编码后的 API Key 并在本地解码(与 `--api-key` 二选一);`--region` 根据地域名自动派生 Token Plan 接入地址(与 `--base-url` 二选一)。
|
||||
- **`bl config agent --context-window`** —— 设置写入 OpenClaw 配置的上下文窗口大小(默认 256000)。
|
||||
- **`bl config agent --wire-api`** —— 选择写入 Codex 配置的通信协议;`chat` 仅保留给 Codex 0.80.0 及更早版本(会显示警告)。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl config agent` 配置 Codex 时默认写入 `wire_api = "responses"`,以适配已不再支持 `chat` 的新版 Codex。
|
||||
- `bl config agent` 配置 Qwen Code 时改用 `DASHSCOPE_API_KEY` 环境变量,不再使用 `BAILIAN_CLI_API_KEY`。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl config agent` 写入的配置现已与各 Agent 官方格式对齐:Claude Code 尊重 `CLAUDE_CONFIG_DIR` 并清理残留的 `ANTHROPIC_API_KEY`;Qwen Code 采用 v3 配置 schema 并正确写入凭证,避免被系统级 `OPENAI_API_KEY` 干扰;OpenCode 支持带注释和尾部逗号的 JSONC 配置文件;OpenClaw 会将主模型注册进模型白名单并补齐计费元数据;Hermes 改用官方扁平 `model.*` 结构;Codex 写入官方 `env_key` 并支持 `auth.json` 兜底。
|
||||
- `bl config agent` 写入配置时现会保留用户已有配置:合并而非覆盖,避免重复添加 provider 条目,并保留用户自定义的显示名。
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### 变更
|
||||
|
||||
- MCP 工具或 WebSearch 因对应服务未开通而不可用时,CLI 现在会提供开通指引和市场直达链接;对于使用旧版 SSE 连接的 WebSearch,还会提示重新开通以切换至 Streamable HTTP。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复文本对话与 API Key 登录校验因传递不受支持的 `enable_thinking` 参数值而产生的兼容性错误。文本对话仅在用户明确开启思考模式时传递该参数,登录校验则改用兼容模型且不再传递该参数。
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl image edit` 新增 `--function` 参数,支持为万相图片编辑模型(如 `wanx2.1-imageedit`)指定编辑功能。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复部分图片模型在图片生成与编辑时的调用失败和尺寸参数错误,并完善 Qwen-Image、Wan/Wanx、Z-Image 系列及 `wanx-v1` 日期版本的兼容性。
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl managed-agent`** —— 通过统一 CLI 声明式管理 Managed Agent 基础设施;百炼 Provider 对接 AgentStudio,并支持 Claude、Qoder 和 Ark:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` —— 基于 `agents.yaml` 初始化、校验、预览和执行资源变更,以及销毁已托管资源。
|
||||
- `state list` / `state show` / `state rm` / `state import` —— 查看和管理本地资源状态,包括纳管已有远端资源或仅解除本地跟踪。
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` —— 完整的会话生命周期操作,支持流式响应和结构化的 `--output json` 输出。
|
||||
- `skill-list` —— 浏览自定义与官方 Skill;使用 `--source all` 可一次返回两个来源。
|
||||
|
||||
### 变更
|
||||
|
||||
- 模型 Base URL 现在统一仅保留 URL Origin;传入的路径、查询参数和 Fragment 不再参与后续 API 请求路径拼接。
|
||||
|
||||
### 修复
|
||||
|
||||
- 安装指南不再推荐已移除的 `--non-interactive`,改为说明显式传入必填参数,并使用 `--output json` 或 `NO_COLOR=1` 适配非交互环境。
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
+59
-77
@@ -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,65 +74,34 @@ 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 安全约束
|
||||
|
||||
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
|
||||
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`。通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -24,9 +24,9 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Text chat** — Qwen3.8-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Image generation & editing** — Qwen-Image 3.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 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
|
||||
@@ -41,6 +41,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Asset center** — Browse and manage model-generated assets (`asset-center list/get/download`), favorites and recycle bin (`favorite`/`delete`), and storage quota (`stats`/`storage`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
@@ -77,11 +78,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Recommended — no Node required
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node users / developers (Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
|
||||
# Agent skills
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
> Binary install does not require Node.js. `npm install -g` remains fully supported.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -140,6 +150,13 @@ bl quota check # Current usage vs rate li
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Asset center — browse, download, and manage model-generated assets (requires console login)
|
||||
bl asset-center list --type IMAGE
|
||||
bl asset-center get <asset-id> --include-download-url
|
||||
bl asset-center download --id <asset-id>
|
||||
bl asset-center stats
|
||||
bl asset-center storage
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
@@ -177,7 +194,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`, `asset-center *`). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -210,8 +227,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# Self-update to latest version
|
||||
# Self-update to latest or a specific version
|
||||
bl update
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
+24
-4
@@ -24,9 +24,9 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **文本对话** — Qwen3.8-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **图像生成与编辑** — Qwen-Image 3.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
@@ -41,6 +41,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **资产中心** — 管理模型生成资产(`asset-center list/get/download`)、收藏与回收站(`favorite`/`delete`)、容量统计(`stats`/`storage`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
@@ -75,11 +76,20 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 推荐 — 无需本机 Node.js
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows(PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node 用户 / 开发者(需要 Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
|
||||
# Agent skills
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -138,6 +148,13 @@ bl quota check # 当前用量 vs 限流
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# 资产中心 — 浏览、下载与管理模型生成资产(需控制台登录)
|
||||
bl asset-center list --type IMAGE
|
||||
bl asset-center get <asset-id> --include-download-url
|
||||
bl asset-center download --id <asset-id>
|
||||
bl asset-center stats
|
||||
bl asset-center storage
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
@@ -175,7 +192,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`、`asset-center *`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -210,6 +227,9 @@ bl config set --key timeout --value 600
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
|
||||
# 安装指定版本
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
# bailian-cli 快速上手指南
|
||||
|
||||
> 本文档面向新加入项目的开发者,帮助你理解 monorepo 的整体架构、代码组织方式和日常开发流程。
|
||||
> AI Agent 维护契约见根目录 [`AGENTS.md`](../AGENTS.md);各场景的详细清单见 [`docs/agents/`](agents/)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目是什么
|
||||
|
||||
**bailian-cli** 是阿里云百炼(DashScope / Model Studio)平台的命令行工具,让用户和 AI Agent 通过终端调用平台的全部 AI 能力:
|
||||
|
||||
- 文本/全模态对话、图像/视频生成与编辑、语音合成与识别
|
||||
- 知识库检索、记忆管理、应用调用、MCP 集成
|
||||
- 微调与部署、数据集管理、配额与业务空间
|
||||
- 控制台能力(用量统计、限流提额、资产中心等)
|
||||
|
||||
仓库以 **pnpm monorepo** 组织,产出两个 npm 产品:
|
||||
|
||||
| 产品 | 包名 | 二进制 | 定位 |
|
||||
| -------------- | ---------------------- | ---------------- | ------------------------------------ |
|
||||
| 百炼全量 CLI | `bailian-cli` | `bl` / `bailian` | 暴露全部命令 |
|
||||
| 知识库轻量 CLI | `knowledge-studio-cli` | `kscli` | 仅 config + knowledge 命令,路径拍平 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 类别 | 选型 |
|
||||
| --------- | -------------------------------------------------------------------------------------------- |
|
||||
| 语言 | TypeScript(strict) |
|
||||
| 运行时 | Node.js ≥ 22.12 |
|
||||
| 包管理 | pnpm 10 + workspace catalog |
|
||||
| 构建/测试 | [vite-plus](https://github.com/voidzero-dev/vite-plus)(`vp check` / `vp test` / `vp pack`) |
|
||||
| HTTP | undici(经 core client 封装) |
|
||||
| 模块 | ESM(`"type": "module"`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心架构:四层分层
|
||||
|
||||
项目按 **「纯逻辑 → 运行时框架 → 命令库 → 产品入口」** 严格分层,职责边界清晰:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 产品入口层 │
|
||||
│ packages/cli (bl) packages/kscli (kscli) │
|
||||
│ 决定命令路径 map、产品 identity、README、技能 reference │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│ createCli(commands, identity)
|
||||
┌────────────────────────────▼────────────────────────────────────┐
|
||||
│ 运行时框架层 packages/runtime (bailian-cli-runtime) │
|
||||
│ 参数解析、命令树/registry、help、middleware、错误处理、输出 │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│ 调用 defineCommand 的 run()
|
||||
┌────────────────────────────▼────────────────────────────────────┐
|
||||
│ 命令库层 packages/commands (bailian-cli-commands) │
|
||||
│ 96+ 命令实现;只导出 command,不决定产品路径 │
|
||||
└────────────────────────────┬────────────────────────────────────┘
|
||||
│ client / settings / auth
|
||||
┌────────────────────────────▼────────────────────────────────────┐
|
||||
│ 纯逻辑层 packages/core (bailian-cli-core) │
|
||||
│ 鉴权、配置、HTTP client、错误、类型、文件工具、领域 API │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 分层边界(必须遵守)
|
||||
|
||||
| 层 | 可以做 | 不能做 |
|
||||
| --------------- | --------------------------- | ------------------------------------------------------------- |
|
||||
| **core** | 纯库逻辑、HTTP、鉴权解析 | 依赖 runtime/commands;硬编码 `bl`/`kscli`;调 `process.exit` |
|
||||
| **runtime** | TTY、help、middleware、输出 | 写具体业务命令逻辑 |
|
||||
| **commands** | 命令元数据 + `run` 实现 | 决定产品路径;在 usage 里写 bin 前缀 |
|
||||
| **cli / kscli** | 命令路径 map、产品 identity | 不写命令业务逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 包详解
|
||||
|
||||
### 4.1 `packages/core` — `bailian-cli-core`
|
||||
|
||||
纯逻辑层,被所有上层依赖。主要模块:
|
||||
|
||||
```
|
||||
packages/core/src/
|
||||
├── auth/ # API Key / Console token 解析与落盘
|
||||
├── client/ # HTTP client、endpoints、MCP、流式解析
|
||||
├── config/ # ~/.bailian/config.json、Settings、来源优先级
|
||||
├── console/ # Console Gateway 调用
|
||||
├── dataset/ # 数据集校验(ChatML/DPO/CPT schema)
|
||||
├── finetune/ # 微调 API 与能力探测
|
||||
├── deploy/ # 部署 API
|
||||
├── advisor/ # 模型推荐(意图识别 + 召回)
|
||||
├── errors/ # BailianError、UsageError、退出码
|
||||
├── output/ # JSON/text 格式化(命令层也可用 runtime 的 emit)
|
||||
├── files/ # 本地文件上传、URL 解析
|
||||
├── telemetry/ # 命令执行遥测
|
||||
└── types/ # Command、FlagsDef、defineCommand
|
||||
```
|
||||
|
||||
**关键类型** — 每个命令通过 `defineCommand` 声明:
|
||||
|
||||
```typescript
|
||||
defineCommand({
|
||||
description: "…",
|
||||
auth: "apiKey" | "console" | "none",
|
||||
flags: {
|
||||
/* camelCase key → kebab-case CLI flag */
|
||||
},
|
||||
usageArgs: "--prompt <text> [flags]", // 不含 bl/kscli 前缀
|
||||
exampleArgs: ['--prompt "hello"'],
|
||||
validate: (flags) => string | undefined, // 跨 flag 校验
|
||||
run: async (ctx) => {
|
||||
/* ctx.client / ctx.flags / ctx.settings */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Client** 是命令的网络入口,凭证已注入,命令层不碰 token:
|
||||
|
||||
```typescript
|
||||
ctx.client.requestJson({ path: "/…", method: "POST", body });
|
||||
ctx.client.console({ product: "…", action: "…", params });
|
||||
ctx.client.uploadFile(localPath);
|
||||
ctx.client.mcp(…);
|
||||
```
|
||||
|
||||
### 4.2 `packages/runtime` — `bailian-cli-runtime`
|
||||
|
||||
通用 CLI 框架,与具体业务无关。核心文件:
|
||||
|
||||
| 文件 | 职责 |
|
||||
| ------------------ | ----------------------------------------------- |
|
||||
| `create-cli.ts` | 入口工厂:`createCli(commands, identity).run()` |
|
||||
| `registry.ts` | 从 `Record<string, AnyCommand>` 建树,动态 help |
|
||||
| `args.ts` | 路径 + flag 解析 |
|
||||
| `middleware.ts` | auth → telemetry → versionCheck → runCommand |
|
||||
| `error-handler.ts` | 统一错误输出与退出码 |
|
||||
| `urls.ts` | 用户面控制台 URL(非 API endpoint) |
|
||||
| `output/` | 颜色、表格、进度条、banner |
|
||||
| `pipeline/` | 多步 pipeline 编排(`bl pipeline run`) |
|
||||
|
||||
**Middleware 流水线**(洋葱模型):
|
||||
|
||||
```
|
||||
argv 解析
|
||||
→ authStage 按 command.auth 注入 apiKey / console 凭证到 ctx.client
|
||||
→ telemetryStage 记录命令执行
|
||||
→ versionCheckStage 检查 npm 更新
|
||||
→ runCommandStage 调用 command.run(ctx)
|
||||
```
|
||||
|
||||
### 4.3 `packages/commands` — `bailian-cli-commands`
|
||||
|
||||
命令实现库,按**能力域**组织目录(≠ 最终 CLI 路径):
|
||||
|
||||
```
|
||||
packages/commands/src/commands/
|
||||
├── text/ # 文本对话
|
||||
├── omni/ # 全模态对话
|
||||
├── image/ # 图像生成/编辑
|
||||
├── video/ # 视频生成/编辑/下载
|
||||
├── speech/ # 语音合成/识别
|
||||
├── vision/ # 图像/视频理解
|
||||
├── knowledge/ # 知识库检索/搜索/对话
|
||||
├── memory/ # 记忆管理
|
||||
├── app/ # 应用调用
|
||||
├── mcp/ # MCP 服务
|
||||
├── auth/ # 登录/登出/状态
|
||||
├── config/ # 配置读写
|
||||
├── console/ # 通用 Console Gateway 调用
|
||||
├── dataset/ # 数据集上传/校验
|
||||
├── finetune/ # 微调任务
|
||||
├── deploy/ # 模型部署
|
||||
├── quota/ # 限流与提额
|
||||
├── workspace/ # 业务空间
|
||||
├── usage/ # 用量统计
|
||||
├── advisor/ # 模型推荐
|
||||
├── asset-center/ # 资产中心(新)
|
||||
├── pipeline/ # Pipeline 编排
|
||||
├── search/ # 联网搜索
|
||||
├── file/ # 文件上传
|
||||
├── token-plan/ # Token 计划
|
||||
└── update.ts # 自更新
|
||||
```
|
||||
|
||||
每个命令文件 `export default defineCommand(…)`,并在 `packages/commands/src/index.ts` 具名 re-export。
|
||||
|
||||
### 4.4 `packages/cli` — `bailian-cli`(`bl`)
|
||||
|
||||
产品入口,极薄:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/main.ts
|
||||
createCli(commands, {
|
||||
binName: "bl",
|
||||
version: pkg.version,
|
||||
clientName: "bailian-cli",
|
||||
npmPackage: "bailian-cli",
|
||||
}).run();
|
||||
```
|
||||
|
||||
**命令路径由 `packages/cli/src/commands.ts` 决定**,例如:
|
||||
|
||||
```typescript
|
||||
export const commands: Record<string, AnyCommand> = {
|
||||
"text chat": textChat,
|
||||
"asset-center list": assetList,
|
||||
"finetune create": finetuneCreate,
|
||||
update, // 单级命令 key 即路径
|
||||
};
|
||||
```
|
||||
|
||||
此文件还被 `tools/generate-reference.ts` 读取,生成 Agent Skill 参考文档。
|
||||
|
||||
### 4.5 `packages/kscli` — `knowledge-studio-cli`(`kscli`)
|
||||
|
||||
轻量 RAG 产品,**复用同一套 commands**,但路径拍平:
|
||||
|
||||
```typescript
|
||||
const commands = {
|
||||
retrieve: knowledgeRetrieve, // ↔ bl knowledge retrieve
|
||||
search: knowledgeSearch, // ↔ bl knowledge search
|
||||
chat: knowledgeChat, // ↔ bl knowledge chat
|
||||
"config show": configShow,
|
||||
update,
|
||||
};
|
||||
```
|
||||
|
||||
同一个 `knowledgeRetrieve` 实现,在 `bl` 显示 `bl knowledge retrieve`,在 `kscli` 显示 `kscli retrieve`——路径完全由产品入口 map 的 key 决定。
|
||||
|
||||
---
|
||||
|
||||
## 5. 一次命令执行的完整链路
|
||||
|
||||
以 `bl text chat --message "hi"` 为例:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant main as cli/main.ts
|
||||
participant createCli as runtime/create-cli.ts
|
||||
participant registry as runtime/registry.ts
|
||||
participant mw as middleware
|
||||
participant cmd as commands/text/chat.ts
|
||||
participant client as core/client
|
||||
|
||||
User->>main: bl text chat --message "hi"
|
||||
main->>createCli: createCli(commands, identity).run(argv)
|
||||
createCli->>registry: 解析路径 ["text","chat"]
|
||||
registry-->>createCli: 匹配 textChat command
|
||||
createCli->>mw: authStage → 注入 apiKey 到 client
|
||||
mw->>cmd: run(ctx)
|
||||
cmd->>client: requestJson / parseSSE
|
||||
client-->>User: stdout 输出
|
||||
```
|
||||
|
||||
**配置与凭证解析优先级**(core 统一处理,命令不介入):
|
||||
|
||||
| 来源 | API Key | Console Token |
|
||||
| ---- | ----------------------- | ------------------------------------- |
|
||||
| 1 | `--api-key` flag | `~/.bailian/config.json` access_token |
|
||||
| 2 | `DASHSCOPE_API_KEY` env | — |
|
||||
| 3 | config.json `api_key` | — |
|
||||
|
||||
Console 命令额外有 `--console-region`、`--workspace-id` 等 flag(由 runtime 按 `auth: "console"` 自动展示)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 鉴权域
|
||||
|
||||
每个命令声明 `auth` 字段,runtime 自动处理:
|
||||
|
||||
| auth 值 | 适用场景 | 凭证来源 | 网络方法 |
|
||||
| ----------- | ------------------------------ | -------------------- | -------------------------------- |
|
||||
| `"apiKey"` | DashScope API(模型推理等) | API Key | `client.request` / `requestJson` |
|
||||
| `"console"` | Console Gateway(控制台能力) | Console access token | `client.console` |
|
||||
| `"none"` | 纯本地(config、update、help) | 无 | 可选 credential-less client |
|
||||
|
||||
**规则**:调用 Console Gateway 的命令必须 `auth: "console"`,且**不要**重复声明 console 凭证域 flags。
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误处理约定
|
||||
|
||||
CLI **只翻译自己能权威解释的错误**,服务端错误原样透传:
|
||||
|
||||
| 错误来源 | 处理 |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| 缺参、flag 校验 | `UsageError` → 退出码 2 |
|
||||
| 本地无凭证 | `BailianError(AUTH)` |
|
||||
| 网络/DNS/TLS | `BailianError(NETWORK)` |
|
||||
| HTTP 4xx/5xx、业务错码 | message **原样透传**,不二次包装 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发工作流
|
||||
|
||||
### 8.1 环境准备
|
||||
|
||||
```bash
|
||||
# 要求 Node >= 22.12, pnpm >= 10
|
||||
pnpm install
|
||||
|
||||
# 格式化 + lint + 类型检查
|
||||
pnpm run check # 或 vp check
|
||||
|
||||
# 本地跑 bl(tsx 直跑,无需 build)
|
||||
pnpm run bl -- text chat --help
|
||||
pnpm run kscli -- search --help
|
||||
|
||||
# 全量测试
|
||||
pnpm test # 或 vp test
|
||||
|
||||
# 构建所有包
|
||||
pnpm run ready # check + test + build
|
||||
```
|
||||
|
||||
### 8.2 新增一个 `bl` 命令(最小路径)
|
||||
|
||||
假设新增 `bl widget do`:
|
||||
|
||||
**Step 1** — 实现命令(`packages/commands`)
|
||||
|
||||
```bash
|
||||
# 新建
|
||||
packages/commands/src/commands/widget/do.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { defineCommand, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const FLAGS = {
|
||||
name: { type: "string", valueHint: "<name>", description: "Widget name", required: true },
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Do something with a widget",
|
||||
auth: "apiKey", // 或 "console" / "none"
|
||||
flags: FLAGS,
|
||||
usageArgs: "--name <name>",
|
||||
exampleArgs: ['--name "demo"'],
|
||||
async run(ctx) {
|
||||
const data = await ctx.client.requestJson({ path: "/…", method: "POST", body: { … } });
|
||||
emitResult(ctx, data);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2** — 导出(`packages/commands/src/index.ts`)
|
||||
|
||||
```typescript
|
||||
export { default as widgetDo } from "./commands/widget/do.ts";
|
||||
```
|
||||
|
||||
**Step 3** — 注册产品路径(`packages/cli/src/commands.ts`)
|
||||
|
||||
```typescript
|
||||
import { widgetDo } from "bailian-cli-commands";
|
||||
// …
|
||||
"widget do": widgetDo,
|
||||
```
|
||||
|
||||
**Step 4** — E2E 测试(`packages/cli/tests/e2e/widget.e2e.test.ts`)
|
||||
|
||||
见 [docs/agents/cli-e2e-tests.md](agents/cli-e2e-tests.md):至少覆盖分组 help、`--help`、缺参用例。
|
||||
|
||||
**Step 5** — 验证
|
||||
|
||||
```bash
|
||||
vp check
|
||||
vp test
|
||||
pnpm run bl -- widget do --help
|
||||
```
|
||||
|
||||
> 若 `kscli` 也需要暴露:在 `packages/kscli/src/main.ts` 的 map 里加 key。
|
||||
> 技能 reference 会在 pre-commit 时由 `generate-reference.ts` 自动从 `commands.ts` 生成。
|
||||
|
||||
详细清单 → [docs/agents/command-add-remove.md](agents/command-add-remove.md)
|
||||
|
||||
### 8.3 给已有命令加 flag
|
||||
|
||||
→ [docs/agents/command-flag-change.md](agents/command-flag-change.md)
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试体系
|
||||
|
||||
```
|
||||
packages/cli/tests/
|
||||
├── e2e/ # 33 个 e2e 测试文件
|
||||
│ ├── helpers.ts # runCli、环境变量 readiness 判断
|
||||
│ ├── global-setup.ts
|
||||
│ └── <topic>.e2e.test.ts
|
||||
└── stress/ # 多能力并发压测
|
||||
├── run.mjs
|
||||
└── targets/
|
||||
```
|
||||
|
||||
**E2E 双层结构**(固定模式):
|
||||
|
||||
```typescript
|
||||
// 层 1:永远跑 — help / 分组,无需 API Key
|
||||
describe("e2e: asset-center", () => {
|
||||
test("asset-center 分组展示子命令帮助且成功退出", …);
|
||||
test("asset-center list --help 正常退出", …);
|
||||
});
|
||||
|
||||
// 层 2:skipIf 缺凭证 — dry-run / 真实集成
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: asset-center(Console …)", () => {
|
||||
test("缺少 --asset-id 时退出为用法错误 (2)", …);
|
||||
test("真实 list 流程", …);
|
||||
});
|
||||
```
|
||||
|
||||
环境变量(常用):
|
||||
|
||||
| 变量 | 用途 |
|
||||
| --------------------------------------------- | ------------------------- |
|
||||
| `DASHSCOPE_API_KEY` | 模型 API 集成测试 |
|
||||
| Console token(经 `bl auth login --console`) | 控制台命令测试 |
|
||||
| `BAILIAN_E2E_*` | 各能力开关(视频/媒体等) |
|
||||
|
||||
压测:`pnpm run test:stress`
|
||||
|
||||
---
|
||||
|
||||
## 10. 命令能力地图(`bl` 全量)
|
||||
|
||||
当前 `packages/cli/src/commands.ts` 注册的命令组:
|
||||
|
||||
| 命令组 | 子命令示例 | auth 域 |
|
||||
| -------------- | ------------------------------------------------------------------------------- | ---------------- |
|
||||
| `auth` | login, status, logout | none / console |
|
||||
| `text` | chat | apiKey |
|
||||
| `omni` | (全模态对话) | apiKey |
|
||||
| `image` | generate, edit | apiKey |
|
||||
| `video` | generate, edit, ref, task get, download | apiKey |
|
||||
| `vision` | describe | apiKey |
|
||||
| `speech` | synthesize, recognize | apiKey |
|
||||
| `knowledge` | retrieve, search, chat | apiKey |
|
||||
| `memory` | add, search, list, update, delete, profile create/get | apiKey |
|
||||
| `app` | call, list | apiKey / console |
|
||||
| `mcp` | call, list, tools | apiKey |
|
||||
| `search` | web | apiKey |
|
||||
| `file` | upload | apiKey |
|
||||
| `config` | show, set | none |
|
||||
| `console` | call | console |
|
||||
| `usage` | free, freetier, stats | console |
|
||||
| `workspace` | list | console |
|
||||
| `quota` | list, request, history, check | console |
|
||||
| `dataset` | upload, list, get, delete, validate | console |
|
||||
| `finetune` | create, list, get, cancel, delete, logs, checkpoints, export, watch, capability | console |
|
||||
| `deploy` | create, list, get, models, scale, update, delete | console |
|
||||
| `token-plan` | list-seats, create-key, assign-seats, add-member | console |
|
||||
| `asset-center` | list, get, favorite, unfavorite, delete, download, stats, storage | console |
|
||||
| `pipeline` | run, validate | apiKey |
|
||||
| `advisor` | recommend | apiKey |
|
||||
| `update` | (自更新) | none |
|
||||
|
||||
---
|
||||
|
||||
## 11. 非代码资产
|
||||
|
||||
```
|
||||
tools/
|
||||
├── generate-reference.ts # 从 cli/commands.ts → skills/bailian-cli/reference/
|
||||
├── sync-skill-metadata.ts # 同步 SKILL.md 版本号
|
||||
└── release/ # CI 发版自动化
|
||||
|
||||
skills/bailian-cli/ # Agent Skill(npx skills add modelstudioai/cli)
|
||||
.github/workflows/ # CI/CD(publish.yml 等)
|
||||
docs/agents/ # 各维护场景的 AI 清单
|
||||
```
|
||||
|
||||
根脚本:
|
||||
|
||||
```bash
|
||||
pnpm run sync:skill-assets # build + 生成 reference + 同步版本
|
||||
pnpm run release:check # 发版前校验
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 发布
|
||||
|
||||
- 版本号:`packages/core`、`runtime`、`commands`、`cli`、`kscli` **保持同步**
|
||||
- 发布范围:`tools/release/lib/packages.mjs` 定义
|
||||
- `bailian-cli` 走常规定义发布;`knowledge-studio-cli` 走 `--knowledge` 通道
|
||||
- 详见 [docs/agents/publish.md](agents/publish.md)
|
||||
|
||||
---
|
||||
|
||||
## 13. 关键文件速查
|
||||
|
||||
| 我想… | 看这里 |
|
||||
| ------------------- | --------------------------------------- |
|
||||
| 了解项目契约 | `AGENTS.md` |
|
||||
| 改 `bl` 命令路径 | `packages/cli/src/commands.ts` |
|
||||
| 写/改命令逻辑 | `packages/commands/src/commands/<域>/` |
|
||||
| 导出命令 | `packages/commands/src/index.ts` |
|
||||
| 改 CLI 框架行为 | `packages/runtime/src/` |
|
||||
| 改 HTTP/鉴权/配置 | `packages/core/src/` |
|
||||
| 改 kscli 路径 | `packages/kscli/src/main.ts` |
|
||||
| 加 E2E 测试 | `packages/cli/tests/e2e/` |
|
||||
| 改控制台 URL | `packages/runtime/src/urls.ts` |
|
||||
| 改 API endpoint | `packages/core/src/client/endpoints.ts` |
|
||||
| 改配置 schema | `packages/core/src/config/schema.ts` |
|
||||
| 生成 Agent 参考文档 | `tools/generate-reference.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 14. 场景导航(维护清单)
|
||||
|
||||
| 场景 | 文档 |
|
||||
| ----------- | ------------------------------------------------------- |
|
||||
| 命令增删改 | [command-add-remove.md](agents/command-add-remove.md) |
|
||||
| E2E 测试 | [cli-e2e-tests.md](agents/cli-e2e-tests.md) |
|
||||
| 加/改 flag | [command-flag-change.md](agents/command-flag-change.md) |
|
||||
| 模型上下架 | [model-add-remove.md](agents/model-add-remove.md) |
|
||||
| 错误文案 | [error-hint-change.md](agents/error-hint-change.md) |
|
||||
| 鉴权扩展 | [auth-change.md](agents/auth-change.md) |
|
||||
| 配置项扩展 | [config-add.md](agents/config-add.md) |
|
||||
| 发布 | [publish.md](agents/publish.md) |
|
||||
| 工具链/lint | [lint-toolchain.md](agents/lint-toolchain.md) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 架构设计要点(读懂代码的钥匙)
|
||||
|
||||
1. **命令实现 ≠ 产品路径** — 同一 `knowledgeRetrieve` 可以是 `bl knowledge retrieve` 或 `kscli retrieve`。
|
||||
2. **defineCommand 是契约** — `auth` + `flags` + `run(ctx)` 是命令的全部接口;凭证和网络细节下沉到 core/runtime。
|
||||
3. **registry 从 map 建树** — `"asset-center list"` 等 path 自动变成命令组,help 动态生成。
|
||||
4. **flags 用 camelCase 定义** — runtime 渲染为 `--kebab-case`;`ParsedFlags<typeof FLAGS>` 提供类型安全。
|
||||
5. **dry-run 是全局 flag** — `--dry-run` 在 auth stage 有例外处理,命令在 `run` 开头判断 `ctx.settings.dryRun`。
|
||||
6. **本地路径即 URL** — 所有接受 URL 的参数同时支持本地文件路径,core `files/upload` 自动上传。
|
||||
7. **Console Gateway 统一入口** — 控制台 API 走 `client.console({ product, action, params })`,不散落 raw fetch。
|
||||
|
||||
---
|
||||
|
||||
## 16. 本地配置速览
|
||||
|
||||
配置文件:`~/.bailian/config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "sk-…",
|
||||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"access_token": "…",
|
||||
"console_region": "cn-beijing",
|
||||
"console_site": "domestic"
|
||||
}
|
||||
```
|
||||
|
||||
常用环境变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ---------------------------- | -------------------------- |
|
||||
| `DASHSCOPE_API_KEY` | 模型 API Key |
|
||||
| `DASHSCOPE_BASE_URL` | API Base URL |
|
||||
| `BAILIAN_WORKSPACE_ID` | 业务空间 ID |
|
||||
| `HTTP_PROXY` / `HTTPS_PROXY` | 代理(runtime 启动时读取) |
|
||||
|
||||
登录:
|
||||
|
||||
```bash
|
||||
bl auth login # API Key
|
||||
bl auth login --console # Console token(扫码)
|
||||
bl auth status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_文档版本:基于仓库当前结构(含 `asset-center`、`kscli`);`packages/rag` 已演进为 `packages/kscli`。_
|
||||
@@ -43,7 +43,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
解析分工:
|
||||
|
||||
- `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一去除 query、fragment、尾斜杠和已知 SDK/API Base 后缀,同时保留自定义网关前缀
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment)
|
||||
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
|
||||
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile;未传
|
||||
`--config` 时继续写当前激活项,失败和 dry-run 不切换
|
||||
@@ -53,6 +53,23 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
|
||||
|
||||
### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
|
||||
|
||||
`bl managed-agent *` 按调用链分两层:
|
||||
|
||||
- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言
|
||||
- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁,authStage 都经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
|
||||
|
||||
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:
|
||||
|
||||
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
|
||||
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
|
||||
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
|
||||
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
|
||||
5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
|
||||
|
||||
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. core 层(类型 + 解析)
|
||||
@@ -104,7 +121,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
### D. 用户面文档
|
||||
|
||||
- [ ] `README.md` / `README.zh.md` "Authentication" 段落
|
||||
- [ ] `skills/bailian-cli/reference/` 通过 `pnpm run sync:skill-assets` 重建
|
||||
- [ ] 各 `skills/<skill>/reference/` 通过 `pnpm run sync:skill-assets` 重建
|
||||
|
||||
### E. 测试
|
||||
|
||||
|
||||
@@ -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. 完成检查
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 安装文档变更
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
|
||||
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
|
||||
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. CLI 契约
|
||||
|
||||
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
|
||||
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
|
||||
- [ ] Node.js 用户安装要求与 `packages/cli/package.json` 的 `engines.node` 一致,不使用根 `package.json` 的开发环境要求
|
||||
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
|
||||
|
||||
### B. 静态副本
|
||||
|
||||
- [ ] 将 `INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
|
||||
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
|
||||
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
|
||||
|
||||
### C. 线上验证
|
||||
|
||||
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
|
||||
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
|
||||
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli test -- tests/install-doc.test.ts
|
||||
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
|
||||
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
|
||||
- 根 `package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
|
||||
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
|
||||
@@ -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
|
||||
@@ -20,6 +20,8 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
|
||||
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
|
||||
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
|
||||
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
|
||||
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
|
||||
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
@@ -49,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:"
|
||||
|
||||
@@ -2,4 +2,7 @@ node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
outputs/
|
||||
outputs/
|
||||
# agents
|
||||
agents.state.json
|
||||
.env
|
||||
|
||||
+23
-5
@@ -24,9 +24,9 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Text chat** — Qwen3.8-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Image generation & editing** — Qwen-Image 3.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 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
|
||||
@@ -41,6 +41,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Model recommendation** — Describe your scenario and get best-fit model suggestions; supports scoped search, model comparison, and alternative discovery
|
||||
- **Fine-tuning & deployment** — Upload datasets, create text/audio/image fine-tune jobs (`finetune text|audio|image create`; text covers SFT/LoRA/DPO/CPT), probe job status non-blockingly (`finetune watch`), query per-model training capability (`finetune capability`), and deploy trained models as endpoints (`deploy text|audio|image create`)
|
||||
- **Console capabilities** — Browse the model marketplace (`model list`) and Bailian apps (`app list`), review a unified usage view (`usage summary`), check free-tier quota (`usage free`), view model usage statistics (`usage stats`), manage workspaces (`workspace list`), and manage rate limits (`quota list/request/check/history`)
|
||||
- **Asset center** — Browse and manage model-generated assets (`asset-center list/get/download`), favorites and recycle bin (`favorite`/`delete`), and storage quota (`stats`/`storage`)
|
||||
- **Local file auto-upload** — Every URL parameter accepts a local path; uploaded to free temp storage with 48-hour validity
|
||||
|
||||
## Showcase: One-Sentence Cinematic Video
|
||||
@@ -77,11 +78,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Recommended — no Node required
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node users / developers (Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
|
||||
# Agent skills
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 18.17.
|
||||
> Binary install does not require Node.js. `npm install -g` remains fully supported.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -140,6 +150,13 @@ bl quota check # Current usage vs rate li
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # Request a temporary TPM increase
|
||||
bl quota history # View quota-change history
|
||||
|
||||
# Asset center — browse, download, and manage model-generated assets (requires console login)
|
||||
bl asset-center list --type IMAGE
|
||||
bl asset-center get <asset-id> --include-download-url
|
||||
bl asset-center download --id <asset-id>
|
||||
bl asset-center stats
|
||||
bl asset-center storage
|
||||
|
||||
# Token Plan team management (requires AK/SK, see auth below)
|
||||
bl token-plan list-seats # View subscription seat details
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
@@ -177,7 +194,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`, `asset-center *`). Opens the Bailian console in your browser to sign in.
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -210,8 +227,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# Self-update to latest version
|
||||
# Self-update to latest or a specific version
|
||||
bl update
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
@@ -24,9 +24,9 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **文本对话** — Qwen3.8-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **图像生成与编辑** — Qwen-Image 3.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
@@ -41,6 +41,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **模型推荐** — 描述你的场景,智能推荐最适合的模型;支持限定范围搜索、模型对比和替代发现
|
||||
- **微调与部署** — 上传数据集、创建文本/音频/图像调优任务(`finetune text|audio|image create`;文本涵盖 SFT/LoRA/DPO/CPT)、非阻塞探测任务状态(`finetune watch`)、按模型查训练能力(`finetune capability`),并把训练好的模型部署为推理服务(`deploy text|audio|image create`)
|
||||
- **控制台能力** — 浏览模型市场(`model list`)和百炼应用(`app list`),查看统一用量视图(`usage summary`),查询模型免费额度(`usage free`),查看模型用量统计(`usage stats`),管理业务空间(`workspace list`),管理限流与提额(`quota list/request/check/history`)
|
||||
- **资产中心** — 管理模型生成资产(`asset-center list/get/download`)、收藏与回收站(`favorite`/`delete`)、容量统计(`stats`/`storage`)
|
||||
- **本地文件自动上传** — 所有 URL 参数同时支持本地路径,免费临时存储 48 小时
|
||||
|
||||
## 示例:一句话生成一部电影短片
|
||||
@@ -75,11 +76,20 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 推荐 — 无需本机 Node.js
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows(PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node 用户 / 开发者(需要 Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
|
||||
# Agent skills
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -138,6 +148,13 @@ bl quota check # 当前用量 vs 限流
|
||||
bl quota request --model qwen3.6-plus --tpm 6000000 # 申请临时 TPM 提额
|
||||
bl quota history # 查看提额历史记录
|
||||
|
||||
# 资产中心 — 浏览、下载与管理模型生成资产(需控制台登录)
|
||||
bl asset-center list --type IMAGE
|
||||
bl asset-center get <asset-id> --include-download-url
|
||||
bl asset-center download --id <asset-id>
|
||||
bl asset-center stats
|
||||
bl asset-center storage
|
||||
|
||||
# Token Plan 团队版管理(需 AK/SK,见下方认证说明)
|
||||
bl token-plan list-seats # 查看订阅席位明细
|
||||
bl token-plan add-member --account-name dev --org-id org_xxx
|
||||
@@ -175,7 +192,7 @@ bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`、`asset-center *`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
@@ -210,6 +227,9 @@ bl config set --key timeout --value 600
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
|
||||
# 安装指定版本
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.10.1",
|
||||
"version": "1.14.1",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.zh.md"
|
||||
"README.zh.md",
|
||||
"postinstall.js"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -40,17 +41,19 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference'",
|
||||
"generate:reference": "tsx ../../tools/generate-reference.ts && sh -c 'cd ../.. && vp check --fix skills/bailian-cli/reference skills/bailian-gen/reference skills/bailian-finetune/reference skills/bailian-managed-agent/reference'",
|
||||
"sync:skill-version": "tsx ../../tools/sync-skill-metadata.ts",
|
||||
"build": "vp pack",
|
||||
"dev": "tsx src/main.ts",
|
||||
"test": "vp test",
|
||||
"check": "vp check"
|
||||
"check": "vp check",
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-commands": "workspace:*",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*"
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"tar-stream": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -84,11 +84,40 @@ import {
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
tokenPlanAddMember,
|
||||
assetList,
|
||||
assetGet,
|
||||
assetFavorite,
|
||||
assetUnfavorite,
|
||||
assetDelete,
|
||||
assetDownload,
|
||||
assetStats,
|
||||
assetStorage,
|
||||
workspaceInit,
|
||||
pluginInstall,
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
skillAdd,
|
||||
skillUpdate,
|
||||
skillRemove,
|
||||
skillList,
|
||||
managedAgentInit,
|
||||
managedAgentValidate,
|
||||
managedAgentPlan,
|
||||
managedAgentApply,
|
||||
managedAgentDestroy,
|
||||
managedAgentStateList,
|
||||
managedAgentStateShow,
|
||||
managedAgentStateRm,
|
||||
managedAgentStateImport,
|
||||
managedAgentSessionCreate,
|
||||
managedAgentSessionList,
|
||||
managedAgentSessionGet,
|
||||
managedAgentSessionDelete,
|
||||
managedAgentSessionRun,
|
||||
managedAgentSessionSend,
|
||||
managedAgentSessionEvents,
|
||||
managedAgentSkillList,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
@@ -181,9 +210,38 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
"token-plan add-member": tokenPlanAddMember,
|
||||
"asset-center list": assetList,
|
||||
"asset-center get": assetGet,
|
||||
"asset-center favorite": assetFavorite,
|
||||
"asset-center unfavorite": assetUnfavorite,
|
||||
"asset-center delete": assetDelete,
|
||||
"asset-center download": assetDownload,
|
||||
"asset-center stats": assetStats,
|
||||
"asset-center storage": assetStorage,
|
||||
"workspace init": workspaceInit,
|
||||
"plugin install": pluginInstall,
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
"skill add": skillAdd,
|
||||
"skill update": skillUpdate,
|
||||
"skill remove": skillRemove,
|
||||
"skill list": skillList,
|
||||
"managed-agent init": managedAgentInit,
|
||||
"managed-agent validate": managedAgentValidate,
|
||||
"managed-agent plan": managedAgentPlan,
|
||||
"managed-agent apply": managedAgentApply,
|
||||
"managed-agent destroy": managedAgentDestroy,
|
||||
"managed-agent state list": managedAgentStateList,
|
||||
"managed-agent state show": managedAgentStateShow,
|
||||
"managed-agent state rm": managedAgentStateRm,
|
||||
"managed-agent state import": managedAgentStateImport,
|
||||
"managed-agent session create": managedAgentSessionCreate,
|
||||
"managed-agent session list": managedAgentSessionList,
|
||||
"managed-agent session get": managedAgentSessionGet,
|
||||
"managed-agent session delete": managedAgentSessionDelete,
|
||||
"managed-agent session run": managedAgentSessionRun,
|
||||
"managed-agent session send": managedAgentSessionSend,
|
||||
"managed-agent session events": managedAgentSessionEvents,
|
||||
"managed-agent skill-list": managedAgentSkillList,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isConsoleE2EReady, isConsoleAuthFailure, parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
describe("e2e: asset-center", () => {
|
||||
test("asset-center 分组展示子命令帮助且成功退出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli(["asset-center"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
expect(output).toContain("list");
|
||||
expect(output).toContain("storage");
|
||||
expect(output).not.toContain("oss");
|
||||
});
|
||||
|
||||
test("asset-center list --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "list", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--type");
|
||||
expect(stderr).toContain("--recycle-bin");
|
||||
expect(stderr).toContain("bl asset-center list");
|
||||
});
|
||||
|
||||
test("asset-center get --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "get", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--asset-id");
|
||||
});
|
||||
|
||||
test("asset-center favorite --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "favorite", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--id");
|
||||
});
|
||||
|
||||
test("asset-center delete --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "delete", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--permanent");
|
||||
});
|
||||
|
||||
test("asset-center download --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "download", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--id");
|
||||
expect(stderr).not.toMatch(/(^|\s)--out(\s|$)/);
|
||||
});
|
||||
|
||||
test("asset-center stats --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "stats", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--sync-failed");
|
||||
});
|
||||
|
||||
test("asset-center storage --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "storage", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("bl asset-center storage");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isConsoleE2EReady())("e2e: asset-center(Console)", () => {
|
||||
test("asset-center get 缺少 --asset-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "get", "--quiet"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--asset-id|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("asset-center favorite 缺少 --id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "favorite", "--quiet"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--id|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("asset-center download 缺少 --id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCli(["asset-center", "download", "--quiet"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--id|Missing required argument/i);
|
||||
});
|
||||
|
||||
test("asset-center list --dry-run 输出请求参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"asset-center",
|
||||
"list",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
api?: string;
|
||||
data?: { deleteStatus?: string };
|
||||
}>(stdout);
|
||||
expect(data.api).toContain("listModelGeneratedAsset");
|
||||
expect(data.data?.deleteStatus).toBe("NORMAL");
|
||||
});
|
||||
|
||||
test("asset-center stats --dry-run 输出请求参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"asset-center",
|
||||
"stats",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ api?: string }>(stdout);
|
||||
expect(data.api).toContain("countModelGeneratedAsset");
|
||||
});
|
||||
|
||||
test("asset-center storage --dry-run 输出请求参数", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCli([
|
||||
"asset-center",
|
||||
"storage",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ api?: string }>(stdout);
|
||||
expect(data.api).toContain("getStorageQuota");
|
||||
});
|
||||
|
||||
test("【console】asset-center list 真实调用或鉴权失败优雅退出", async () => {
|
||||
const workspaceId = process.env.BAILIAN_WORKSPACE_ID;
|
||||
const args = ["asset-center", "list", "--output", "json", "--page-size", "1"];
|
||||
if (workspaceId) args.push("--workspace-id", workspaceId);
|
||||
|
||||
const result = await runCli(args);
|
||||
if (isConsoleAuthFailure(result)) return;
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
|
||||
import { monorepoRoot } from "e2e/monorepo-root";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { commands } from "../src/commands.ts";
|
||||
|
||||
const repositoryRoot = monorepoRoot();
|
||||
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
|
||||
const cliPackage = JSON.parse(
|
||||
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
|
||||
) as {
|
||||
engines?: { node?: string };
|
||||
};
|
||||
|
||||
function toFlagName(key: string): string {
|
||||
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
function findDocumentedCommand(snippet: string): {
|
||||
commandPath?: string;
|
||||
command?: AnyCommand;
|
||||
} {
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
const commandPath = Object.keys(commands)
|
||||
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
|
||||
.find((candidatePath) => {
|
||||
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
|
||||
});
|
||||
|
||||
return commandPath ? { commandPath, command: commands[commandPath] } : {};
|
||||
}
|
||||
|
||||
function documentedCommandSnippets(): string[] {
|
||||
const fencedCommands = installGuide
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("bl "));
|
||||
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
|
||||
return [...new Set([...fencedCommands, ...inlineCommands])];
|
||||
}
|
||||
|
||||
describe("INSTALL.md", () => {
|
||||
test("发布包 Node.js 要求与安装文档一致", () => {
|
||||
const nodeEngine = cliPackage.engines?.node;
|
||||
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
|
||||
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
|
||||
});
|
||||
|
||||
test("示例只使用当前命令支持的 flags", () => {
|
||||
for (const snippet of documentedCommandSnippets()) {
|
||||
const { commandPath, command } = findDocumentedCommand(snippet);
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
|
||||
if (!commandPath || !command) {
|
||||
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
|
||||
}
|
||||
|
||||
const supportedFlags = {
|
||||
...GLOBAL_FLAGS,
|
||||
...(command ? credentialFlagDefs(command) : {}),
|
||||
...command?.flags,
|
||||
};
|
||||
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
|
||||
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
|
||||
const unsupportedFlagNames = usedFlagNames.filter(
|
||||
(flagName) => !supportedFlagNames.has(flagName),
|
||||
);
|
||||
|
||||
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag:${snippet}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.10.1",
|
||||
"version": "1.14.1",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
@@ -40,6 +40,7 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openagentpack/sdk": "0.3.1",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GetModelsOptions,
|
||||
getModels,
|
||||
type IntentProfile,
|
||||
maybeSyncWikiData,
|
||||
type PipelineStep,
|
||||
type RecommendedModel,
|
||||
type RecommendResult,
|
||||
@@ -248,6 +249,12 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const userInput = flags.message;
|
||||
const top = 3;
|
||||
|
||||
// Keep the local wiki catalog fresh: throttled (12h) version check against
|
||||
// the remote manifest, silently replaces data when a newer version exists.
|
||||
// Never throws — a sync failure must not block recommendation.
|
||||
await maybeSyncWikiData();
|
||||
|
||||
// Default to JSON for structured output; render boxen cards only when the
|
||||
// user explicitly asked for text output.
|
||||
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
# 资产中心 CLI 命令树设计
|
||||
|
||||
> 本文档定义 `bl asset` 命令族的路径结构、help 层级、flags 概览与示例。
|
||||
> 技术实现细节见 [DESIGN.md](./DESIGN.md);API 字段见 [api-doc.md](./api-doc.md)。
|
||||
|
||||
## 1. 命名原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------- |
|
||||
| 产品路径前缀 | `asset`(不用 `asset-center`,与 `deploy` / `dataset` 等产品域一致) |
|
||||
| 层级深度 | 最多三级:`asset <group> <action>` |
|
||||
| 子组条件 | 仅当子组下 ≥ 2 个 action 时使用子组(见 AGENTS.md) |
|
||||
| bin 前缀 | `usageArgs` / `exampleArgs` 不写 `bl`;help 由 runtime 按路径补全 |
|
||||
| 鉴权 | 全部 `auth: "console"`;自动可见 `--console-region` 等 CONSOLE_AUTH_FLAGS |
|
||||
|
||||
---
|
||||
|
||||
## 2. 命令树总览
|
||||
|
||||
```
|
||||
bl asset
|
||||
│
|
||||
├── list # 分页查询资产列表
|
||||
├── get <asset-id> # 查询单个资产详情
|
||||
├── favorite # 收藏资产
|
||||
├── unfavorite # 取消收藏
|
||||
├── delete # 删除资产(默认软删到回收站)
|
||||
├── restore # 从回收站恢复
|
||||
├── download # 获取下载链接 / 可选落盘
|
||||
├── stats # 资产数量统计
|
||||
├── storage # 存储容量与配额
|
||||
│
|
||||
├── models # [P1] 模型列表(辅助筛选)
|
||||
│ └── list
|
||||
│
|
||||
├── service # [P1/P2] 服务开通状态
|
||||
│ ├── status
|
||||
│ ├── enable # [P2]
|
||||
│ └── disable # [P2]
|
||||
│
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品入口注册 Map
|
||||
|
||||
`packages/cli/src/commands.ts` 中预期注册(camelCase export → kebab path):
|
||||
|
||||
| Map Key | Export 名(建议) | Phase |
|
||||
| ------------------------- | --------------------- | ----- |
|
||||
| `"asset list"` | `assetList` | 1 |
|
||||
| `"asset get"` | `assetGet` | 1 |
|
||||
| `"asset favorite"` | `assetFavorite` | 1 |
|
||||
| `"asset unfavorite"` | `assetUnfavorite` | 1 |
|
||||
| `"asset delete"` | `assetDelete` | 1 |
|
||||
| `"asset restore"` | `assetRestore` | 1 |
|
||||
| `"asset download"` | `assetDownload` | 1 |
|
||||
| `"asset stats"` | `assetStats` | 1 |
|
||||
| `"asset storage"` | `assetStorage` | 1 |
|
||||
| `"asset models list"` | `assetModelsList` | 2 |
|
||||
| `"asset service status"` | `assetServiceStatus` | 2 |
|
||||
| `"asset service enable"` | `assetServiceEnable` | 3 |
|
||||
| `"asset service disable"` | `assetServiceDisable` | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Help 层级预览
|
||||
|
||||
### 4.1 顶层分组
|
||||
|
||||
```
|
||||
$ bl asset
|
||||
Asset management commands for Bailian Asset Center.
|
||||
|
||||
Commands:
|
||||
list List model-generated assets
|
||||
get Get asset details by ID
|
||||
favorite Mark assets as favorites
|
||||
unfavorite Remove assets from favorites
|
||||
delete Delete assets (soft delete by default)
|
||||
restore Restore soft-deleted assets
|
||||
download Get asset download URLs
|
||||
stats Count assets by type
|
||||
storage View storage quota and usage
|
||||
models Model configuration helpers
|
||||
service Asset center service subscription
|
||||
|
||||
Run `bl asset <command> --help` for details.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 各命令规格
|
||||
|
||||
以下 `usageArgs` 为命令 metadata 中的值(不含 global flags)。Global flags(`--output`、`--dry-run`、`--quiet` 等)与 console flags(`--workspace-id` 等)由 runtime 自动追加到 help。
|
||||
|
||||
---
|
||||
|
||||
### 5.1 Phase 1 命令
|
||||
|
||||
#### `bl asset list`
|
||||
|
||||
```
|
||||
Description: List model-generated assets with filters and cursor pagination
|
||||
|
||||
Usage: bl asset list [flags]
|
||||
|
||||
Flags:
|
||||
--type <type> Asset type: IMAGE, VIDEO, AUDIO
|
||||
--model <name> Filter by model name
|
||||
--keyword <text> Filter by asset name (substring)
|
||||
--favorited Show only favorited assets
|
||||
--recycle-bin Show soft-deleted assets (recycle bin)
|
||||
--sync-status <status> OSS sync status filter
|
||||
--begin-time <datetime> Filter by generate time start (ISO_LOCAL_DATE_TIME)
|
||||
--end-time <datetime> Filter by generate time end
|
||||
--include-download-url Include signed download URLs
|
||||
--include-thumbnail Include thumbnail URLs
|
||||
--thumbnail-width <px> Thumbnail width
|
||||
--thumbnail-height <px> Thumbnail height
|
||||
--page-size <n> Page size (default: 10, max: 100)
|
||||
--next-token <token> Cursor for next page
|
||||
--pre-token <token> Cursor for previous page
|
||||
|
||||
Examples:
|
||||
bl asset list
|
||||
bl asset list --type IMAGE --model qwen-image-3.0
|
||||
bl asset list --favorited --page-size 20
|
||||
bl asset list --recycle-bin
|
||||
bl asset list --keyword landscape --output json
|
||||
```
|
||||
|
||||
**PRD 映射:** #1 查看资产列表
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset get <asset-id>`
|
||||
|
||||
```
|
||||
Description: Get full details of a model-generated asset
|
||||
|
||||
Usage: bl asset get <asset-id> [flags]
|
||||
|
||||
Arguments:
|
||||
<asset-id> Asset ID to query
|
||||
|
||||
Flags:
|
||||
--asset-id <id> Asset ID (alternative to positional)
|
||||
--include-download-url Include signed download URL
|
||||
--include-thumbnail Include thumbnail URL
|
||||
--thumbnail-width <px> Thumbnail width
|
||||
--thumbnail-height <px> Thumbnail height
|
||||
|
||||
Examples:
|
||||
bl asset get asset-001
|
||||
bl asset get asset-001 --include-download-url --output json
|
||||
```
|
||||
|
||||
**PRD 映射:** #2 查看资产详情
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset favorite`
|
||||
|
||||
```
|
||||
Description: Add assets to favorites
|
||||
|
||||
Usage: bl asset favorite --id <asset-id> [--id <asset-id>...]
|
||||
|
||||
Flags:
|
||||
--id <asset-id> Asset ID (repeatable, max 100, required)
|
||||
|
||||
Examples:
|
||||
bl asset favorite --id asset-001
|
||||
bl asset favorite --id asset-001 --id asset-002
|
||||
```
|
||||
|
||||
**PRD 映射:** #3 收藏
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset unfavorite`
|
||||
|
||||
```
|
||||
Description: Remove assets from favorites
|
||||
|
||||
Usage: bl asset unfavorite --id <asset-id> [--id <asset-id>...]
|
||||
|
||||
Flags:
|
||||
--id <asset-id> Asset ID (repeatable, max 100, required)
|
||||
|
||||
Examples:
|
||||
bl asset unfavorite --id asset-001
|
||||
bl asset unfavorite --id asset-001 --id asset-002
|
||||
```
|
||||
|
||||
**PRD 映射:** #3 取消收藏
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset delete`
|
||||
|
||||
```
|
||||
Description: Delete assets (soft delete to recycle bin by default)
|
||||
|
||||
Usage: bl asset delete --id <asset-id> [--id <asset-id>...] [flags]
|
||||
|
||||
Flags:
|
||||
--id <asset-id> Asset ID (repeatable, max 100, required)
|
||||
--permanent Permanently delete (cannot be restored)
|
||||
|
||||
Examples:
|
||||
bl asset delete --id asset-001
|
||||
bl asset delete --id asset-001 --id asset-002
|
||||
bl asset delete --id asset-001 --permanent
|
||||
```
|
||||
|
||||
**PRD 映射:** #4 删除资产、#5 批量删除
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset restore`
|
||||
|
||||
```
|
||||
Description: Restore soft-deleted assets from recycle bin
|
||||
|
||||
Usage: bl asset restore --id <asset-id> [--id <asset-id>...]
|
||||
|
||||
Flags:
|
||||
--id <asset-id> Asset ID (repeatable, max 100, required)
|
||||
|
||||
Examples:
|
||||
bl asset restore --id asset-001
|
||||
bl asset restore --id asset-001 --id asset-002
|
||||
```
|
||||
|
||||
**PRD 映射:** 补充能力(配合回收站)
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset download`
|
||||
|
||||
```
|
||||
Description: Get signed download URLs for assets
|
||||
|
||||
Usage: bl asset download --id <asset-id> [--id <asset-id>...] [--out <path>]
|
||||
|
||||
Flags:
|
||||
--id <asset-id> Asset ID (repeatable, max 100, required)
|
||||
--out <path> Save file to path (only when exactly one --id)
|
||||
|
||||
Examples:
|
||||
bl asset download --id asset-001
|
||||
bl asset download --id asset-001 --out ./image.png
|
||||
bl asset download --id asset-001 --id asset-002 --output json
|
||||
```
|
||||
|
||||
**PRD 映射:** #6 下载资产
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset stats`
|
||||
|
||||
```
|
||||
Description: Count model-generated assets by type
|
||||
|
||||
Usage: bl asset stats [flags]
|
||||
|
||||
Flags:
|
||||
--type <type> Filter by asset type
|
||||
--model <name> Filter by model name
|
||||
--keyword <text> Filter by asset name
|
||||
--favorited Count only favorited assets
|
||||
--recycle-bin Count soft-deleted assets
|
||||
--sync-failed Also count assets with failed OSS sync
|
||||
--begin-time <datetime> Filter by generate time start
|
||||
--end-time <datetime> Filter by generate time end
|
||||
|
||||
Examples:
|
||||
bl asset stats
|
||||
bl asset stats --sync-failed
|
||||
bl asset stats --type IMAGE --output json
|
||||
```
|
||||
|
||||
**PRD 映射:** #7 查看资产统计
|
||||
|
||||
**text 输出示例:**
|
||||
|
||||
```
|
||||
Total: 200
|
||||
Image: 150
|
||||
Video: 30
|
||||
Audio: 20
|
||||
Sync failed: 5 # 仅 --sync-failed 时出现
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset storage`
|
||||
|
||||
```
|
||||
Description: View storage quota, usage, and overage pricing
|
||||
|
||||
Usage: bl asset storage [flags]
|
||||
|
||||
Examples:
|
||||
bl asset storage
|
||||
bl asset storage --output json
|
||||
```
|
||||
|
||||
**PRD 映射:** #14 查看容量信息
|
||||
|
||||
**text 输出示例:**
|
||||
|
||||
```
|
||||
Used: 1.2 GB
|
||||
Free quota: 5.0 GB
|
||||
Overage: ¥0.12/GB/month
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Phase 2/3 可选命令
|
||||
|
||||
#### `bl asset models list`
|
||||
|
||||
```
|
||||
Description: List managed models grouped by asset type
|
||||
|
||||
Usage: bl asset models list
|
||||
|
||||
Examples:
|
||||
bl asset models list --output json
|
||||
```
|
||||
|
||||
用途:配合 `bl asset list --model` 时查阅可用 modelId。
|
||||
|
||||
---
|
||||
|
||||
#### `bl asset service status`
|
||||
|
||||
```
|
||||
Description: Check whether asset center service is enabled
|
||||
|
||||
Usage: bl asset service status
|
||||
|
||||
Examples:
|
||||
bl asset service status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 6. PRD 覆盖矩阵
|
||||
|
||||
| PRD # | 功能 | CLI 命令 | Phase | 状态 |
|
||||
| ----- | ------------- | ------------------------------- | ----- | ---------------------------------- |
|
||||
| 1 | 查看资产列表 | `asset list` | 1 | ✅ 可开发 |
|
||||
| 2 | 查看资产详情 | `asset get` | 1 | ✅ 可开发 |
|
||||
| 3 | 收藏/取消收藏 | `asset favorite` / `unfavorite` | 1 | ✅ 可开发 |
|
||||
| 4 | 删除资产 | `asset delete` | 1 | ✅ 可开发 |
|
||||
| 5 | 批量删除 | `asset delete`(多 `--id`) | 1 | ✅ 可开发 |
|
||||
| 6 | 下载资产 | `asset download` | 1 | ✅ 可开发 |
|
||||
| 7 | 查看资产统计 | `asset stats` | 1 | ✅ 可开发(转存失败用 workaround) |
|
||||
| 14 | 查看容量信息 | `asset storage` | 1 | ✅ 可开发 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 典型工作流
|
||||
|
||||
### 7.1 首次使用
|
||||
|
||||
```bash
|
||||
bl auth login --console
|
||||
bl config set workspace_id ws-xxxxx
|
||||
bl asset service status # 可选:确认已开通
|
||||
bl asset storage # 查看容量
|
||||
```
|
||||
|
||||
### 7.2 浏览与筛选
|
||||
|
||||
```bash
|
||||
bl asset list
|
||||
bl asset list --type IMAGE --model qwen-image-3.0 --keyword landscape
|
||||
bl asset list --favorited
|
||||
bl asset list --recycle-bin
|
||||
bl asset get asset-001 --include-download-url
|
||||
bl asset stats
|
||||
bl asset stats --sync-failed
|
||||
```
|
||||
|
||||
### 7.3 资产管理
|
||||
|
||||
```bash
|
||||
bl asset favorite --id asset-001
|
||||
bl asset unfavorite --id asset-001
|
||||
bl asset delete --id asset-001
|
||||
bl asset delete --id asset-001 --id asset-002
|
||||
bl asset restore --id asset-001
|
||||
bl asset download --id asset-001 --out ./image.png
|
||||
```
|
||||
|
||||
### 7.5 脚本翻页(JSON)
|
||||
|
||||
```bash
|
||||
# 第一页
|
||||
bl asset list --page-size 50 --output json
|
||||
# 后续页(使用响应中的 nextToken)
|
||||
bl asset list --page-size 50 --next-token 1000 --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 与现有命令的风格对齐
|
||||
|
||||
| 参考命令 | 对齐点 |
|
||||
| ------------------------------ | -------------------------------------------------- |
|
||||
| `bl app list` | console gateway 调用、dry-run 输出 `{ api, data }` |
|
||||
| `bl dataset list` | text 表格 + json items 结构 |
|
||||
| `bl deploy list/get/create` | 产品域子命令命名、多级 path |
|
||||
| `bl memory profile get/create` | 三级 path 子组 |
|
||||
| `bl quota list` | `zeldaHttp.*` API 名、响应 extract |
|
||||
| `bl video download` | `--out` 落盘 |
|
||||
| `bl usage stats` | `requireWorkspaceId`、console E2E 模式 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 变更记录
|
||||
|
||||
| 日期 | 版本 | 说明 |
|
||||
| ---------- | ---- | ---------------------------------------------- |
|
||||
| 2026-07-09 | 0.1 | 初稿:命令树、PRD 映射、分 Phase 规格 |
|
||||
| 2026-08-07 | 0.2 | 取消 OSS 转存命令(`oss *` / `transfer list`) |
|
||||
@@ -0,0 +1,408 @@
|
||||
# 资产中心 CLI 设计文档
|
||||
|
||||
> 本文档描述 `bl asset` 命令族的技术设计方案,供开发、评审与联调使用。
|
||||
> 接口字段细节见同目录 [api-doc.md](./api-doc.md);命令路径与 help 结构见 [COMMAND-TREE.md](./COMMAND-TREE.md)。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
百炼资产中心(Asset Center)提供模型生成资产的存储、检索、收藏、删除与容量管理能力。产品 PRD 要求 CLI 覆盖以下模块:
|
||||
|
||||
| 模块 | PRD 能力 |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| 资产管理 | 列表、详情、收藏/取消收藏、删除、批量删除、下载 |
|
||||
| 资产统计 | 总量、按类型统计 |
|
||||
| 容量 | 已用容量、免费额度、超额单价 |
|
||||
|
||||
后端接口通过 **Zelda HTTP 网关** 暴露,Base Path 为 `/zelda/api/v1/bailian/asset`,详见 [api-doc.md](./api-doc.md)。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
- 在 `packages/commands` 实现可复用命令库,由 `packages/cli/src/commands.ts` 注册为 `bl asset ...` 产品路径
|
||||
- 遵循 monorepo 分层约定:`commands` 不写产品 bin 前缀;Console Gateway 命令统一 `auth: "console"`
|
||||
- 服务端错误原样透传;CLI 仅对参数校验、缺凭证、网络失败等内部错误发出语义化 `BailianError`
|
||||
- 支持 `--dry-run`、`--output json`、text 表格输出等现有 CLI 惯例
|
||||
|
||||
### 1.3 非目标
|
||||
|
||||
- 不在 `rag` 入口暴露(首期与 `deploy` / `finetune` 一致,仅 `bl`)
|
||||
- 不暴露 `sendMqMessage` 等内部 MQ 接口
|
||||
- 不在 `core` / `runtime` 层硬编码 `bl` 命令名或控制台 URL
|
||||
|
||||
---
|
||||
|
||||
## 2. PRD → API → CLI 映射
|
||||
|
||||
### 2.1 资产管理
|
||||
|
||||
| PRD # | 能力 | CLI 命令 | API Action | 备注 |
|
||||
| ----- | ------------- | ------------------------------------------- | --------------------------------------------- | ------------------------------------------ |
|
||||
| 1 | 查看资产列表 | `bl asset list` | `listModelGeneratedAsset` | 游标分页;支持类型/模型/关键词/收藏/回收站 |
|
||||
| 2 | 查看资产详情 | `bl asset get <asset-id>` | `getModelGeneratedAsset` | positional 或 `--asset-id` |
|
||||
| 3 | 收藏/取消收藏 | `bl asset favorite` / `bl asset unfavorite` | `batchFavoriteAsset` / `batchUnfavoriteAsset` | 单 ID 也走 batch(长度 1) |
|
||||
| 4 | 删除资产 | `bl asset delete` | `batchDeleteAsset` | 默认 `SOFT_DELETE`(移入回收站) |
|
||||
| 5 | 批量删除 | `bl asset delete` | `batchDeleteAsset` | `--id` 可重复,最多 100 |
|
||||
| 6 | 下载资产 | `bl asset download` | `batchGetAssetDownloadUrl` | 默认输出 URL;单资产可选 `--out` 落盘 |
|
||||
|
||||
**建议补充(API 已有、PRD 未写):**
|
||||
|
||||
| 能力 | CLI 命令 | API Action |
|
||||
| ------------ | ------------------ | ------------------- |
|
||||
| 从回收站恢复 | `bl asset restore` | `batchRestoreAsset` |
|
||||
|
||||
### 2.2 资产统计
|
||||
|
||||
| PRD # | 能力 | CLI 命令 | API Action | 备注 |
|
||||
| ----- | ------------ | ---------------- | -------------------------- | --------------------------------------- |
|
||||
| 7 | 查看资产统计 | `bl asset stats` | `countModelGeneratedAsset` | 输出 total / image / video / audio 计数 |
|
||||
|
||||
**转存失败数(PRD 子项):**
|
||||
|
||||
- API 支持 `syncOssDataStatus=SYNC_FAILED` 筛选,但无独立 `failureCount` 字段
|
||||
- **Phase 1 方案**:`bl asset stats --sync-failed` 额外发起一次 count 查询,输出 `sync_failed_count`
|
||||
- **Phase 3 备选**:等后端在 stats 响应中增加专用字段后收敛
|
||||
|
||||
### 2.4 容量
|
||||
|
||||
| PRD # | 能力 | CLI 命令 | API Action |
|
||||
| ----- | ------------ | ------------------ | ----------------- |
|
||||
| 14 | 查看容量信息 | `bl asset storage` | `getStorageQuota` |
|
||||
|
||||
### 2.5 可选扩展(API 有、PRD 未列)
|
||||
|
||||
| CLI 命令 | API Action | 优先级 |
|
||||
| ------------------------------------- | ------------------------------- | -------------------- |
|
||||
| `bl asset service status` | `checkAssetServiceSubscription` | P1 |
|
||||
| `bl asset service enable` / `disable` | `subscribeAssetService` | P2 |
|
||||
| `bl asset models list` | `listModels` | P1(配合 list 筛选) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构与分层
|
||||
|
||||
### 3.1 在 monorepo 中的位置
|
||||
|
||||
```
|
||||
packages/commands/src/commands/asset-center/*.ts ← 命令实现(本目录)
|
||||
↓ export
|
||||
packages/commands/src/index.ts
|
||||
↓ import + map key
|
||||
packages/cli/src/commands.ts ← "asset list": assetList, ...
|
||||
↓
|
||||
packages/runtime (createCli / authStage / registry)
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 实现文件按能力组织在本目录
|
||||
- `usageArgs` / `exampleArgs` 不含 `bl` 前缀
|
||||
- 所有 asset 命令 `auth: "console"`;不重复声明 `CONSOLE_AUTH_FLAGS`(runtime 自动注入)
|
||||
|
||||
### 3.2 目录结构
|
||||
|
||||
```
|
||||
asset-center/
|
||||
├── api-doc.md # 后端 API 文档(已有)
|
||||
├── DESIGN.md # 本文档
|
||||
├── COMMAND-TREE.md # 命令树与 help 结构
|
||||
├── types.ts # TypeScript 类型(ModelGeneratedAssetItem 等)
|
||||
├── utils.ts # 公共请求构建、API 调用、响应解析
|
||||
├── list.ts
|
||||
├── get.ts
|
||||
├── favorite.ts
|
||||
├── unfavorite.ts
|
||||
├── delete.ts
|
||||
├── download.ts
|
||||
├── stats.ts
|
||||
└── storage.ts
|
||||
```
|
||||
|
||||
### 3.3 共享层 `utils.ts`
|
||||
|
||||
参考 `token-plan/utils.ts`、`usage/stats.ts` 的 `requireWorkspaceId` 模式。
|
||||
|
||||
#### 3.3.1 API 名称约定
|
||||
|
||||
与 `quota/list.ts` 中 `zeldaHttp.dashscopeModel./zelda/api/v1/...` 类似,资产中心预期为:
|
||||
|
||||
```typescript
|
||||
const ASSET_SERVICE = "bailianAsset"; // ⚠️ 编码前需 spike 确认
|
||||
const ASSET_BASE = "/zelda/api/v1/bailian/asset";
|
||||
|
||||
function assetApi(action: string): string {
|
||||
return `zeldaHttp.${ASSET_SERVICE}.${ASSET_BASE}/${action}`;
|
||||
}
|
||||
```
|
||||
|
||||
编码第一步用 `bl console call --api <name> --data '{...}'` 验证实际注册名。
|
||||
|
||||
#### 3.3.2 公共请求体
|
||||
|
||||
所有接口继承 `AssetHttpBaseRequest`(见 api-doc §公共请求参数):
|
||||
|
||||
| 字段 | CLI 来源 | 状态 |
|
||||
| ---------------- | --------------------------------------------------------- | ---------- |
|
||||
| `workspace` | `settings.workspaceId`(`--workspace-id` / env / config) | ✅ 已有 |
|
||||
| `tenantId` | 待定 | ⚠️ 需确认 |
|
||||
| `mainAccountUid` | 待定 | ⚠️ 需确认 |
|
||||
| `apiSource` | 固定 `"CLI"` | 实现时写入 |
|
||||
| `aliYunUid` 等 | 网关 session 注入或省略 | 待确认 |
|
||||
|
||||
`requireWorkspaceId(settings, binName)` 在缺少 workspace 时抛出 `BailianError(GENERAL)`,hint 指向 `bl workspace list`。
|
||||
|
||||
#### 3.3.3 调用封装
|
||||
|
||||
```typescript
|
||||
async function callAssetApi<T>(
|
||||
ctx: CommandRunContext,
|
||||
action: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const payload = { ...buildBaseRequest(ctx), ...body };
|
||||
const raw = await ctx.client.console(assetApi(action), payload);
|
||||
return extractAssetResponse<T>(raw);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.4 响应解析
|
||||
|
||||
Console Gateway 响应可能存在多层嵌套(参考 `quota/list.ts` 的 `extractResponseData`):
|
||||
|
||||
1. 剥 gateway 外层:`data` → `DataV2` → `data` → ...
|
||||
2. 到达业务 `Result<T>`:`{ success, code, message, data }`
|
||||
3. 若 `success === false`:抛 `BailianError(GENERAL, message)`,**不翻译、不替换** message
|
||||
4. 成功时返回 `data` 字段
|
||||
|
||||
---
|
||||
|
||||
## 4. 命令实现规范
|
||||
|
||||
### 4.1 通用模式
|
||||
|
||||
每个命令文件遵循:
|
||||
|
||||
```typescript
|
||||
export default defineCommand({
|
||||
description: "...",
|
||||
auth: "console",
|
||||
usageArgs: "...",
|
||||
flags: { ... },
|
||||
exampleArgs: ["...", "--output json"],
|
||||
validate(ctx) { /* 跨 flag 条件校验 */ },
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
if (ctx.settings.dryRun) {
|
||||
emitResult({ api: assetApi("..."), data: { ... } }, format);
|
||||
return;
|
||||
}
|
||||
const data = await callAssetApi(ctx, "actionName", { ... });
|
||||
// text 表格 或 emitResult(json)
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
参考实现:`app/list.ts`(console + dry-run)、`dataset/list.ts`(表格输出)、`video/download.ts`(落盘)。
|
||||
|
||||
### 4.2 分页模型(`asset list`)
|
||||
|
||||
**与 `app list` 不同**:资产列表使用 **id 游标分页**,不是 page/pageSize 页码模式。
|
||||
|
||||
| Flag | API 字段 | 说明 |
|
||||
| -------------- | ----------- | -------------------------- |
|
||||
| `--page-size` | `pageSize` | 默认 10,最大 100 |
|
||||
| `--next-token` | `nextToken` | 下一页游标(来自上次响应) |
|
||||
| `--pre-token` | `preToken` | 上一页游标 |
|
||||
|
||||
JSON 输出保留 `nextToken` / `preToken` / `hasNext` / `hasPre`,便于脚本翻页。
|
||||
|
||||
### 4.3 批量 ID 传参
|
||||
|
||||
批量操作(favorite / unfavorite / delete / restore / download)统一:
|
||||
|
||||
```typescript
|
||||
id: {
|
||||
type: "array",
|
||||
valueHint: "<asset-id>",
|
||||
description: "Asset ID(s) to operate on (repeatable, max 100)",
|
||||
required: true,
|
||||
}
|
||||
```
|
||||
|
||||
CLI 用法:`--id asset-001 --id asset-002` 或多次重复。实现时在 `validate` 中校验 `ids.length <= 100`。
|
||||
|
||||
### 4.4 输出格式
|
||||
|
||||
| 命令 | text 默认 | json |
|
||||
| ---------- | -------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `list` | 表格:assetId / type / name / model / favorited / generateTime | items + pagination |
|
||||
| `get` | 关键字段摘要 | 完整 item |
|
||||
| `stats` | 数字摘要 | `{ total_count, image_count, ... }` |
|
||||
| `storage` | 人类可读字节 + 单价 | 原始 quota 字段 |
|
||||
| 写操作 | 一行确认(affectedCount) | `{ success, affected_count }` |
|
||||
| `download` | URL 列表或 saved 路径 | `{ items: [{ asset_id, download_url }] }` |
|
||||
|
||||
使用 `formatTable`(`dataset/list.ts`)、`formatBytes`(`video/download.ts`)、`emitResult` / `emitBare`。
|
||||
|
||||
### 4.5 条件校验(`validate`)
|
||||
|
||||
| 命令 | 规则 |
|
||||
| ---------- | --------------------------------------------------------- |
|
||||
| `delete` | `--permanent` 映射 `PERMANENT_DELETE`;默认 `SOFT_DELETE` |
|
||||
| 所有 batch | `assetIdList.length <= 100` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键命令 Flag 详设
|
||||
|
||||
### 5.1 `bl asset list`
|
||||
|
||||
| Flag | 类型 | API 映射 | 说明 |
|
||||
| ------------------------ | ---------------- | ---------------------------- | ------------------------------------------------------------ |
|
||||
| `--type` | string (choices) | `assetType` | `IMAGE` / `VIDEO` / `AUDIO` |
|
||||
| `--model` | string | `modelName` | PRD「按模型筛选」 |
|
||||
| `--keyword` | string | `assetName` | PRD「关键词」;是否同时搜 description 待产品确认 |
|
||||
| `--favorited` | switch | `favorited: true` | 仅看收藏 |
|
||||
| `--recycle-bin` | switch | `deleteStatus: SOFT_DELETED` | 仅看回收站 |
|
||||
| `--sync-status` | string (choices) | `syncOssDataStatus` | `NOT_SYNCED` / `IN_SYNCING` / `SYNC_SUCCESS` / `SYNC_FAILED` |
|
||||
| `--begin-time` | string | `beginTime` | ISO_LOCAL_DATE_TIME |
|
||||
| `--end-time` | string | `endTime` | ISO_LOCAL_DATE_TIME |
|
||||
| `--include-download-url` | switch | `includeDownloadUrl` | |
|
||||
| `--include-thumbnail` | switch | `includeThumbnail` | |
|
||||
| `--thumbnail-width` | number | `thumbnailWidth` | 配合 thumbnail |
|
||||
| `--thumbnail-height` | number | `thumbnailHeight` | 配合 thumbnail |
|
||||
| `--page-size` | number | `pageSize` | |
|
||||
| `--next-token` | number | `nextToken` | |
|
||||
| `--pre-token` | number | `preToken` | |
|
||||
|
||||
### 5.2 `bl asset get`
|
||||
|
||||
| 参数/Flag | 说明 |
|
||||
| ------------------------------------------ | --------------------------------------- |
|
||||
| `<asset-id>` | positional,primary |
|
||||
| `--asset-id` | 与 positional 二选一(positional 优先) |
|
||||
| `--include-download-url` | |
|
||||
| `--include-thumbnail` | |
|
||||
| `--thumbnail-width` / `--thumbnail-height` | |
|
||||
|
||||
### 5.3 `bl asset delete`
|
||||
|
||||
| Flag | 说明 |
|
||||
| ------------- | --------------------------------------------------------- |
|
||||
| `--id` | array, required, max 100 |
|
||||
| `--permanent` | switch → `deleteType: PERMANENT_DELETE`;默认 SOFT_DELETE |
|
||||
|
||||
### 5.4 `bl asset download`
|
||||
|
||||
| Flag | 说明 |
|
||||
| ------- | ----------------------------------------------------- |
|
||||
| `--id` | array, required |
|
||||
| `--out` | 仅当 `--id` 恰好 1 个时有效;调用 `downloadFile` 落盘 |
|
||||
|
||||
### 5.5 `bl asset stats`
|
||||
|
||||
| Flag | 说明 |
|
||||
| ------------------------------------- | ---------------------------------------------- |
|
||||
| (无 filter) | 默认 `deleteStatus: NORMAL` |
|
||||
| `--recycle-bin` | `deleteStatus: SOFT_DELETED` |
|
||||
| `--sync-failed` | 额外查询 `syncOssDataStatus: SYNC_FAILED` 计数 |
|
||||
| `--type` / `--model` / `--keyword` 等 | 与 list 相同筛选维度(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与待确认项
|
||||
|
||||
### 6.1 P0 — 编码前必须对齐
|
||||
|
||||
| # | 问题 | 影响 | 建议动作 |
|
||||
| --- | ------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------- |
|
||||
| 1 | Console API 注册名(`zeldaHttp.{service}./zelda/api/v1/bailian/asset/*`) | 无法调用 | `bl console call` spike;与后端确认 service 名 |
|
||||
| 2 | `tenantId` / `mainAccountUid` 由谁填充 | 所有接口必填 | 确认网关是否从 session 自动注入;否则扩展 config 或新增解析 API |
|
||||
|
||||
### 6.2 P1 — 产品设计
|
||||
|
||||
| # | 问题 | 建议默认 |
|
||||
| --- | -------------------- | ----------------------------------------------- |
|
||||
| 4 | 关键词搜索范围 | 仅 `assetName`;后续可加 `--search-description` |
|
||||
| 5 | PRD 只提 image/video | CLI 暴露 IMAGE/VIDEO/AUDIO(与 API 一致) |
|
||||
| 6 | 下载行为 | 默认输出 URL;单 ID + `--out` 落盘 |
|
||||
| 7 | 永久删除 | 提供 `--permanent`,help 注明不可恢复 |
|
||||
| 8 | 服务未开通 | 不预检查;失败时透传服务端 message |
|
||||
| 9 | 收藏命令形态 | 两个命令 `favorite` / `unfavorite`(语义清晰) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误处理
|
||||
|
||||
遵循 [AGENTS.md](../../../../../../AGENTS.md) 错误边界:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --------------------------------- | --------------------------------------------- |
|
||||
| 缺 `--workspace-id` | `BailianError(GENERAL)` + hint |
|
||||
| 缺 console token | authStage 抛 `BailianError(AUTH)` |
|
||||
| flag 校验失败 | `UsageError` (exit 2) |
|
||||
| HTTP 4xx/5xx / 业务 success=false | `BailianError(GENERAL)`,message **原样透传** |
|
||||
| batch ID > 100 | `UsageError` |
|
||||
|
||||
Console 未登录参考 `mcp/list.ts`:检测 `BailianGateway.Login.NotLogined` 时 hint 指向 `bl auth login --console`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试策略
|
||||
|
||||
新建 `packages/cli/tests/e2e/asset.e2e.test.ts`,遵循 [cli-e2e-tests.md](../../../../../../docs/agents/cli-e2e-tests.md)。
|
||||
|
||||
### 8.1 不 skip 层
|
||||
|
||||
- `bl asset` 分组 help
|
||||
- 各子命令 `--help`
|
||||
- 缺参 → exit 2
|
||||
|
||||
### 8.2 Console skip 层(`isConsoleE2EReady()`)
|
||||
|
||||
- 各命令 `--dry-run` 输出 api + data
|
||||
- 真实 `asset list` / `asset storage` 集成(需已开通资产中心的工作空间)
|
||||
|
||||
环境:`BAILIAN_E2E=1` + console `access_token` + `BAILIAN_WORKSPACE_ID`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 注册与文档变更清单
|
||||
|
||||
| 文件 | 变更 |
|
||||
| -------------------------------------------------- | ------------------- |
|
||||
| `packages/commands/src/commands/asset-center/*.ts` | 新建 |
|
||||
| `packages/commands/src/index.ts` | export |
|
||||
| `packages/cli/src/commands.ts` | 注册 map |
|
||||
| `packages/cli/tests/e2e/asset.e2e.test.ts` | 新建 |
|
||||
| `skills/bailian-cli/reference/` | pre-commit 自动生成 |
|
||||
| `README.md` / `README.zh.md` | 发版前补充命令一览 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 分期实施
|
||||
|
||||
### Phase 1 — 核心资产(P0)
|
||||
|
||||
```
|
||||
asset list | get | favorite | unfavorite | delete | restore | download | stats | storage
|
||||
```
|
||||
|
||||
**前置:** §6.1 #1 #2 确认。
|
||||
|
||||
### Phase 2+ — 可选扩展
|
||||
|
||||
```
|
||||
asset models list | service status | service enable/disable
|
||||
```
|
||||
|
||||
> OSS 转存相关命令(`asset-center oss *` / `transfer list`)已取消,不再排期。
|
||||
|
||||
## 11. 参考
|
||||
|
||||
- 命令注册:[docs/agents/command-add-remove.md](../../../../../../docs/agents/command-add-remove.md)
|
||||
- E2E 规范:[docs/agents/cli-e2e-tests.md](../../../../../../docs/agents/cli-e2e-tests.md)
|
||||
- Console 命令样例:`packages/commands/src/commands/app/list.ts`
|
||||
- 游标/表格:`packages/commands/src/commands/quota/list.ts`
|
||||
- workspace 必填:`packages/commands/src/commands/usage/stats.ts`
|
||||
- 文件落盘:`packages/commands/src/commands/video/download.ts`
|
||||
@@ -0,0 +1,35 @@
|
||||
# Asset Center 命令测试报告 — Phase 2
|
||||
|
||||
- **测试时间**: 2026-07-10 09:07:09 (UTC)
|
||||
- **Workspace**: `llm-0xvms4kqhbqjlg8s`
|
||||
- **测试 IMAGE**: `asset_98175cbf83294f7b8ada86657623dcf3`
|
||||
- **测试 VIDEO**: `asset_df026105d2274ff9b8c824058fa23d60`
|
||||
- **策略**: 可逆写操作(favorite/unfavorite 往返);download 到 /tmp 后删除;其余只读
|
||||
- **汇总**: 16 通过 / 0 失败 / 16 总计
|
||||
|
||||
> Phase 1 报告见同目录 [TEST-REPORT.md](./TEST-REPORT.md)(24 项 dry-run + 只读基础验证)
|
||||
|
||||
## Phase 2 测试结果
|
||||
|
||||
| # | 分类 | 命令 | 模式 | 状态 | Exit | 耗时 | 结果摘要 |
|
||||
| --- | ---- | ------------------------------------------ | -------- | ------- | ---- | ------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | 下载 | `asset-center download (IMAGE)` | 真实调用 | ✅ PASS | 0 | 20045ms | saved /tmp/asset-center-test-asset_98175cbf83294f7b8ada86657623dcf3.png (1449847 bytes, reported 1.4 MB) |
|
||||
| 2 | 查询 | `asset-center get --include-download-url` | 真实调用 | ✅ PASS | 0 | 21226ms | download_url present |
|
||||
| 3 | 查询 | `asset-center list --include-download-url` | 真实调用 | ✅ PASS | 0 | 19057ms | items contain download_url |
|
||||
| 4 | 查询 | `asset-center list --next-token` | 真实调用 | ✅ PASS | 0 | 19584ms | page2=3 items, overlap=0, has_pre=true |
|
||||
| 5 | 统计 | `asset-center stats --type IMAGE` | 真实调用 | ✅ PASS | 0 | 18830ms | image=7, total=7 |
|
||||
| 6 | 统计 | `asset-center stats --sync-failed` | 真实调用 | ✅ PASS | 0 | 19084ms | total=27, sync_failed=0 |
|
||||
| 7 | 查询 | `asset-center list --recycle-bin` | 真实调用 | ✅ PASS | 0 | 19314ms | 0 soft-deleted item(s) |
|
||||
| 8 | 输出 | `asset-center list (text)` | 真实调用 | ✅ PASS | 0 | 17840ms | 4 lines table output |
|
||||
| 9 | 收藏 | `asset-center favorite (真实)` | 真实调用 | ✅ PASS | 0 | 22082ms | affected=1 |
|
||||
| 10 | 收藏 | `get 验证 favorited=true` | 真实调用 | ✅ PASS | 0 | 18865ms | favorited=true ✓ |
|
||||
| 11 | 查询 | `list --favorited 含测试资产` | 真实调用 | ✅ PASS | 0 | 22120ms | found in favorited list |
|
||||
| 12 | 收藏 | `asset-center unfavorite (真实)` | 真实调用 | ✅ PASS | 0 | 22133ms | affected=1 |
|
||||
| 13 | 收藏 | `get 验证 favorited=false (恢复)` | 真实调用 | ✅ PASS | 0 | 27797ms | favorited=false ✓ |
|
||||
| 14 | 收藏 | `favorite 批量 (--id x2)` | 真实调用 | ✅ PASS | 0 | 22947ms | affected=2 |
|
||||
| 15 | 收藏 | `unfavorite 批量 (--id x2)` | 真实调用 | ✅ PASS | 0 | 15385ms | affected=2 |
|
||||
| 16 | 边界 | `get 不存在的 asset-id` | 真实调用 | ✅ PASS | 1 | 12207ms | exit 1, 服务端错误原样透传: "资产不存在" |
|
||||
|
||||
## 边界行为说明
|
||||
|
||||
查询不存在的 `asset-id` 时,服务端返回业务错误 **「资产不存在」**,CLI 按约定 **原样透传**(exit code 1),不会替换为本地文案。这与 AGENTS.md 错误处理边界一致。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Asset Center 命令测试报告
|
||||
|
||||
- **测试时间**: 2026-07-10 08:41:06 (UTC)
|
||||
- **Workspace**: `llm-0xvms4kqhbqjlg8s`
|
||||
- **样本 Asset ID**: `asset_df026105d2274ff9b8c824058fa23d60`
|
||||
- **策略**: 只读命令真实调用;写操作/下载一律 `--dry-run`
|
||||
- **汇总**: 24 通过 / 0 失败 / 24 总计
|
||||
|
||||
## 测试结果
|
||||
|
||||
| # | 分类 | 命令 | 模式 | 状态 | Exit | 耗时 | 结果摘要 |
|
||||
| --- | ---- | ---------------------------------- | -------- | ------- | ---- | ------- | -------------------------------------------------------------- |
|
||||
| 1 | 查询 | `asset-center list` | 真实调用 | ✅ PASS | 0 | 18962ms | 3 item(s), next=94 |
|
||||
| 2 | 查询 | `asset-center list --type IMAGE` | 真实调用 | ✅ PASS | 0 | 16411ms | 2 item(s), next=90 |
|
||||
| 3 | 查询 | `asset-center get` | 真实调用 | ✅ PASS | 0 | 16541ms | {gmtModified, aliyunUid, generateTime, aliyunMainId} |
|
||||
| 4 | 统计 | `asset-center stats` | 真实调用 | ✅ PASS | 0 | 14226ms | total=27 |
|
||||
| 5 | 统计 | `asset-center storage` | 真实调用 | ✅ PASS | 0 | 18622ms | {used_storage_size, free_storage_quota, extra_storage_price} |
|
||||
| 9 | 查询 | `asset-center list --dry-run` | dry-run | ✅ PASS | 0 | 23855ms | dry-run → /zelda/api/v1/bailian/asset/listModelGeneratedAsset |
|
||||
| 10 | 查询 | `asset-center get --dry-run` | dry-run | ✅ PASS | 0 | 17398ms | dry-run → /zelda/api/v1/bailian/asset/getModelGeneratedAsset |
|
||||
| 11 | 收藏 | `asset-center favorite` | dry-run | ✅ PASS | 0 | 16471ms | dry-run → /zelda/api/v1/bailian/asset/batchFavoriteAsset |
|
||||
| 12 | 收藏 | `asset-center unfavorite` | dry-run | ✅ PASS | 0 | 19724ms | dry-run → /zelda/api/v1/bailian/asset/batchUnfavoriteAsset |
|
||||
| 13 | 删除 | `asset-center delete` | dry-run | ✅ PASS | 0 | 20612ms | dry-run → /zelda/api/v1/bailian/asset/batchDeleteAsset |
|
||||
| 14 | 下载 | `asset-center download` | dry-run | ✅ PASS | 0 | 20348ms | dry-run → /zelda/api/v1/bailian/asset/batchGetAssetDownloadUrl |
|
||||
| 15 | 统计 | `asset-center stats --dry-run` | dry-run | ✅ PASS | 0 | 14898ms | dry-run → /zelda/api/v1/bailian/asset/countModelGeneratedAsset |
|
||||
| 16 | 统计 | `asset-center storage --dry-run` | dry-run | ✅ PASS | 0 | 14305ms | dry-run → /zelda/api/v1/bailian/asset/getStorageQuota |
|
||||
| 21 | 校验 | `asset-center get (缺 asset-id)` | 参数校验 | ✅ PASS | 2 | 15202ms | Error: Missing required flag: --asset-id |
|
||||
| 22 | 校验 | `asset-center favorite (缺 --id)` | 参数校验 | ✅ PASS | 2 | 16697ms | Error: Missing required flag: --id |
|
||||
| 23 | 校验 | `asset-center download (缺 --out)` | 参数校验 | ✅ PASS | 2 | 18803ms | Error: Missing required flag: --out |
|
||||
|
||||
## 模式说明
|
||||
|
||||
| 模式 | 说明 |
|
||||
| -------- | -------------------------------------------------- |
|
||||
| 真实调用 | 只读 API,不修改数据 |
|
||||
| dry-run | 输出 `{ api, data, gateway }` 请求体,不发起写操作 |
|
||||
| 参数校验 | 预期 exit code 2(用法错误) |
|
||||
@@ -0,0 +1,624 @@
|
||||
# BailianAssetZeldaHttpService API 文档
|
||||
|
||||
通过 Zelda 网关调用 bailian-asset HTTP 接口文档。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **Base Path**: `/zelda/api/v1/bailian/asset`
|
||||
- **Method**: POST
|
||||
- **Content-Type**: `application/json`
|
||||
- **Accept**: `application/json`
|
||||
|
||||
## 统一响应格式
|
||||
|
||||
所有接口返回 `Result<T>` 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "string",
|
||||
"success": true,
|
||||
"code": "string",
|
||||
"message": "string",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------- | ---------------------- |
|
||||
| requestId | String | 请求唯一ID |
|
||||
| success | Boolean | 是否成功 |
|
||||
| code | String | 错误码(失败时返回) |
|
||||
| message | String | 错误信息(失败时返回) |
|
||||
| data | Object | 业务数据(成功时返回) |
|
||||
|
||||
## 公共请求参数(基类字段)
|
||||
|
||||
所有接口请求体均继承自 `AssetHttpBaseRequest`,包含以下公共字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| -------------- | ------ | ---- | ---------------------------------------------- |
|
||||
| requestId | String | 否 | 请求唯一ID |
|
||||
| apiSource | String | 否 | 调用入口渠道,如 OpenAPI、CloudSDK |
|
||||
| tenantId | String | 是 | 内部租户ID |
|
||||
| workspace | String | 是 | 业务空间ID |
|
||||
| aliYunUid | String | 否 | 阿里云子账号ID |
|
||||
| mainAccountUid | String | 是 | 阿里云主账号ID |
|
||||
| callerType | String | 否 | 账号类型:partner/customer/sub/AssumedRoleUser |
|
||||
| callerParentId | Long | 否 | 调用者所属主账号ID |
|
||||
| accessKeyId | String | 否 | STS认证:用户AccessKeyId |
|
||||
| securityToken | String | 否 | STS认证:扮演者的STS Token |
|
||||
|
||||
---
|
||||
|
||||
## 1. 开通/关闭资产中心服务
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/subscribeAssetService`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------ | ------------ | ---- | --------------------------------------- |
|
||||
| action | String(Enum) | 是 | 操作类型:`ENABLE`-开通,`DISABLE`-关闭 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------ | ------------ | ------------ |
|
||||
| status | String(Enum) | 当前服务状态 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"action": "ENABLE"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 批量收藏资产
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/batchFavoriteAsset`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------------ | ---- | ---------------------------------- |
|
||||
| assetIdList | List<String> | 是 | 待收藏的资产ID列表,长度不超过 100 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | ------- | -------------------- |
|
||||
| success | Boolean | 是否收藏成功 |
|
||||
| affectedCount | Integer | 实际被收藏的资产数量 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"assetIdList": ["asset-001", "asset-002", "asset-003"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 批量取消收藏资产
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/batchUnfavoriteAsset`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------------ | ---- | -------------------------------------- |
|
||||
| assetIdList | List<String> | 是 | 待取消收藏的资产ID列表,长度不超过 100 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | ------- | ------------------------ |
|
||||
| success | Boolean | 是否取消收藏成功 |
|
||||
| affectedCount | Integer | 实际被取消收藏的资产数量 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"assetIdList": ["asset-001", "asset-002"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 批量删除资产
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/batchDeleteAsset`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------------ | ---- | ----------------------------------------------------------- |
|
||||
| assetIdList | List<String> | 是 | 待删除的资产ID列表,长度不超过 100 |
|
||||
| deleteType | String(Enum) | 是 | 删除类型:`SOFT_DELETE`-软删除,`PERMANENT_DELETE`-彻底删除 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | ------- | -------------------- |
|
||||
| success | Boolean | 是否删除成功 |
|
||||
| affectedCount | Integer | 实际被删除的资产数量 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"assetIdList": ["asset-001", "asset-002"],
|
||||
"deleteType": "SOFT_DELETE"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 批量恢复软删除资产
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/batchRestoreAsset`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------------ | ---- | ---------------------------------- |
|
||||
| assetIdList | List<String> | 是 | 待恢复的资产ID列表,长度不超过 100 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | ------- | -------------------- |
|
||||
| success | Boolean | 是否恢复成功 |
|
||||
| affectedCount | Integer | 实际被恢复的资产数量 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"assetIdList": ["asset-001", "asset-002"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 分页查询模型生成资产
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/listModelGeneratedAsset`
|
||||
|
||||
采用 id 游标分页,默认 pageSize=10,最大 100。
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------------- | ------------ | ---- | ---------------------------------------------------------------------------------- |
|
||||
| preToken | Long | 否 | 向前翻页游标 |
|
||||
| nextToken | Long | 否 | 向后翻页游标(查询下一页时传入上一次响应的 nextToken) |
|
||||
| pageSize | Integer | 否 | 每页大小,默认 10,最大 100 |
|
||||
| includeDownloadUrl | Boolean | 否 | 是否返回文件下载链接,默认 false |
|
||||
| includeThumbnail | Boolean | 否 | 是否返回资产缩放图 URL,默认 false |
|
||||
| thumbnailWidth | Integer | 否 | 缩放图宽度(像素),includeThumbnail=true 时生效 |
|
||||
| thumbnailHeight | Integer | 否 | 缩放图高度(像素),includeThumbnail=true 时生效 |
|
||||
| softDeleteTimeOrder | String(Enum) | 否 | 软删除时间排序方式:`ASC`-正序,`DESC`-倒序;仅在 deleteStatus=SOFT_DELETED 时有效 |
|
||||
| assetType | String(Enum) | 否 | 资产类型:`IMAGE`-图片,`VIDEO`-视频,`AUDIO`-音频 |
|
||||
| favorited | Boolean | 否 | 是否被收藏 |
|
||||
| assetName | String | 否 | 资产名称(子串模糊匹配) |
|
||||
| assetDescription | String | 否 | 资产描述(子串模糊匹配) |
|
||||
| trusted | Boolean | 否 | 是否可信 |
|
||||
| modelType | String | 否 | 生成资产的模型类型 |
|
||||
| modelName | String | 否 | 生成资产的模型型号 |
|
||||
| syncWhiteListStatus | String(Enum) | 否 | 同步白名单状态:`NOT_SYNCED` / `SYNC_SUCCESS` / `SYNC_FAILED` |
|
||||
| syncOssDataStatus | String(Enum) | 否 | 同步OSS数据状态:`NOT_SYNCED` / `IN_SYNCING` / `SYNC_SUCCESS` / `SYNC_FAILED` |
|
||||
| deleteStatus | String(Enum) | 否 | 删除状态:`NORMAL` / `SOFT_DELETED` / `PERMANENTLY_DELETED` |
|
||||
| beginTime | String | 否 | 资产生成时间起始(含),格式 ISO_LOCAL_DATE_TIME,如 `2023-10-25T14:30:00` |
|
||||
| endTime | String | 否 | 资产生成时间截止(含),格式 ISO_LOCAL_DATE_TIME,如 `2023-10-25T14:30:00` |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ----------------------------- | ------------ |
|
||||
| dataList | List<ModelGeneratedAssetItem> | 资产列表 |
|
||||
| preToken | Long | 前一页游标 |
|
||||
| nextToken | Long | 下一页游标 |
|
||||
| hasNext | Boolean | 是否有下一页 |
|
||||
| hasPre | Boolean | 是否有前一页 |
|
||||
|
||||
**ModelGeneratedAssetItem 结构:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| id | Long | 主键 ID(分页游标 token) |
|
||||
| gmtCreate | Date | 创建时间 |
|
||||
| gmtModified | Date | 修改时间 |
|
||||
| workspaceId | String | 工作空间ID |
|
||||
| tenantId | String | 租户ID |
|
||||
| aliyunUid | String | 阿里云子账号ID |
|
||||
| aliyunMainId | String | 阿里云主账号ID |
|
||||
| assetId | String | 资产 ID |
|
||||
| assetType | String | 资产类型:IMAGE/VIDEO/AUDIO |
|
||||
| assetSource | String | 资产来源:MODEL_GENERATED/OFFICIAL/USER_UPLOADED |
|
||||
| favorited | Boolean | 是否被收藏 |
|
||||
| assetName | String | 资产名称 |
|
||||
| assetDescription | String | 资产描述 |
|
||||
| assetSize | Long | 资产大小(字节) |
|
||||
| md5 | String | 资产 MD5 |
|
||||
| ossBucket | String | 资产所在 OSS Bucket |
|
||||
| ossKey | String | 资产在 OSS bucket 中的 key |
|
||||
| region | String | 工作空间地域 |
|
||||
| ossRegion | String | 资产所在 OSS bucket 的地域 |
|
||||
| trusted | Boolean | 是否可信 |
|
||||
| modelType | String | 模型类型 |
|
||||
| modelName | String | 模型型号 |
|
||||
| syncWhiteListStatus | String | 同步白名单状态 |
|
||||
| syncOssDataStatus | String | 同步 OSS 数据状态 |
|
||||
| deleteStatus | String | 删除状态:NORMAL/SOFT_DELETED/PERMANENTLY_DELETED |
|
||||
| generateTime | Long | 资产生成时间戳(毫秒) |
|
||||
| softDeleteDays | Integer | 已被软删除的天数(仅当 deleteStatus=SOFT_DELETED 且请求 softDeleteTimeOrder 时返回) |
|
||||
| originalOssUrl | String | 原始 OSS URL |
|
||||
| downloadUrl | String | 文件下载链接(仅当请求 includeDownloadUrl=true 时返回) |
|
||||
| thumbnailUrl | String | 资产缩放图 URL(仅当请求 includeThumbnail=true 时返回;视频返回首帧缩放图,图片返回缩放图,音频返回 null) |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"pageSize": 20,
|
||||
"includeDownloadUrl": true,
|
||||
"includeThumbnail": true,
|
||||
"thumbnailWidth": 200,
|
||||
"thumbnailHeight": 200,
|
||||
"assetType": "IMAGE",
|
||||
"favorited": true,
|
||||
"beginTime": "2024-01-01T00:00:00",
|
||||
"endTime": "2024-12-31T23:59:59"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"dataList": [
|
||||
{
|
||||
"id": 1001,
|
||||
"assetId": "asset-001",
|
||||
"assetType": "IMAGE",
|
||||
"assetName": "generated_image_01.png",
|
||||
"assetDescription": "A landscape painting",
|
||||
"favorited": true,
|
||||
"generateTime": 1700000000000
|
||||
}
|
||||
],
|
||||
"nextToken": 1000,
|
||||
"hasNext": true,
|
||||
"hasPre": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 统计模型生成资产数量
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/countModelGeneratedAsset`
|
||||
|
||||
查询条件与 `listModelGeneratedAsset` 一致(不需要分页参数),按资产类型分组返回数量。
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------------- | ------------ | ---- | ------------------------------------------------ |
|
||||
| assetType | String(Enum) | 否 | 资产类型:`IMAGE` / `VIDEO` / `AUDIO` |
|
||||
| favorited | Boolean | 否 | 是否被收藏 |
|
||||
| assetName | String | 否 | 资产名称(子串模糊匹配) |
|
||||
| assetDescription | String | 否 | 资产描述(子串模糊匹配) |
|
||||
| trusted | Boolean | 否 | 是否可信 |
|
||||
| modelType | String | 否 | 模型类型 |
|
||||
| modelName | String | 否 | 模型型号 |
|
||||
| syncWhiteListStatus | String(Enum) | 否 | 同步白名单状态 |
|
||||
| syncOssDataStatus | String(Enum) | 否 | 同步OSS数据状态 |
|
||||
| deleteStatus | String(Enum) | 否 | 删除状态 |
|
||||
| beginTime | String | 否 | 资产生成时间起始(含),格式 ISO_LOCAL_DATE_TIME |
|
||||
| endTime | String | 否 | 资产生成时间截止(含),格式 ISO_LOCAL_DATE_TIME |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------- | ---- | ---------------- |
|
||||
| imageCount | Long | 图片类型资产数量 |
|
||||
| videoCount | Long | 视频类型资产数量 |
|
||||
| audioCount | Long | 音频类型资产数量 |
|
||||
| totalCount | Long | 总资产数量 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"deleteStatus": "NORMAL"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"imageCount": 150,
|
||||
"videoCount": 30,
|
||||
"audioCount": 20,
|
||||
"totalCount": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 批量获取资产下载链接
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/batchGetAssetDownloadUrl`
|
||||
|
||||
一次最多获取 100 个资产的下载链接。
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------------ | ---- | ------------------------------------------ |
|
||||
| assetIdList | List<String> | 是 | 待获取下载链接的资产ID列表,长度不超过 100 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----- | -------------------------- | -------------------------------- |
|
||||
| items | List<AssetDownloadUrlItem> | 资产下载链接列表,按请求顺序返回 |
|
||||
|
||||
**AssetDownloadUrlItem 结构:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------- | ------ | ---------------------------------------------------------- |
|
||||
| assetId | String | 资产 ID |
|
||||
| downloadUrl | String | 资产下载链接(带签名);资产不存在或缺少 OSS 信息时为 null |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"assetIdList": ["asset-001", "asset-002", "asset-003"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 查询模型生成资产详情
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/getModelGeneratedAsset`
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------------ | ------- | ---- | ------------------------------------------------ |
|
||||
| assetId | String | 是 | 待查询的资产 ID |
|
||||
| includeDownloadUrl | Boolean | 否 | 是否返回文件下载链接,默认 false |
|
||||
| includeThumbnail | Boolean | 否 | 是否返回资产缩放图 URL,默认 false |
|
||||
| thumbnailWidth | Integer | 否 | 缩放图宽度(像素),includeThumbnail=true 时生效 |
|
||||
| thumbnailHeight | Integer | 否 | 缩放图高度(像素),includeThumbnail=true 时生效 |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---- | ----------------------- | ------------------------ |
|
||||
| item | ModelGeneratedAssetItem | 资产详情(结构同第12节) |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"assetId": "asset-001",
|
||||
"includeDownloadUrl": true,
|
||||
"includeThumbnail": true,
|
||||
"thumbnailWidth": 200,
|
||||
"thumbnailHeight": 200
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 获取存储额度与用量
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/getStorageQuota`
|
||||
|
||||
### 请求参数
|
||||
|
||||
仅需公共参数(`workspace`、`tenantId` 必填)。
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------------- | ------ | --------------------------------------------- |
|
||||
| freeStorageQuota | Long | 平台免费存储额度(单位:字节) |
|
||||
| usedStorageSize | Long | 当前用户已使用的存储量(单位:字节) |
|
||||
| extraStoragePrice | String | 超出免费额度的费用说明(如 "¥0.12元/GB/月") |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19. 通用 MQ 消息发送
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/sendMqMessage`
|
||||
|
||||
向指定的 RocketMQ Producer 发送 JSON 格式的消息。producerType 对应 `EnumRocketMqProducerType` 枚举的 code 值,mainAccountUid 作为消息路由 key。
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| ------------ | ------ | ---- | --------------------------------------------------------------------------------------------------- |
|
||||
| producerType | String | 是 | 生产者类型:`WHITE_LIST_ASSET_PRODUCER` / `OSS_DATA_HANDEL_PRODUCER` / `ORIGIN_ASSET_INFO_PRODUCER` |
|
||||
| messageBody | String | 是 | JSON 格式的消息体字符串 |
|
||||
| messageKey | String | 否 | 消息 key(可选,为空时默认使用 mainAccountUid) |
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------- | ------- | ------------ |
|
||||
| success | Boolean | 是否发送成功 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890",
|
||||
"producerType": "ORIGIN_ASSET_INFO_PRODUCER",
|
||||
"messageBody": "{\"time\":1700000000000,\"modelId\":\"model-abc\",\"type\":\"IMAGE\",\"workspace\":\"ws-xxxxx\",\"ossUrl\":\"oss://my-bucket/path/to/asset.png\"}"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 21. 查询模型列表
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/listModels`
|
||||
|
||||
返回当前服务管理的模型配置列表,按资产类型分组,包含每个模型的ID及是否可信标识。
|
||||
|
||||
### 请求参数
|
||||
|
||||
仅需公共参数。
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------- | ---------------- | ---------------------------- |
|
||||
| modelGroups | List<ModelGroup> | 按资产类型分组的模型配置列表 |
|
||||
|
||||
**ModelGroup 结构:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | --------------- | ------------------------------------- |
|
||||
| assetType | String(Enum) | 资产类型:`IMAGE` / `VIDEO` / `AUDIO` |
|
||||
| models | List<ModelItem> | 该类型下管理的模型列表 |
|
||||
|
||||
**ModelItem 结构:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------- | ------- | -------------- |
|
||||
| modelId | String | 模型ID |
|
||||
| trusted | Boolean | 该模型是否可信 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"modelGroups": [
|
||||
{
|
||||
"assetType": "IMAGE",
|
||||
"models": [
|
||||
{ "modelId": "qwen-image-3.0", "trusted": true },
|
||||
{ "modelId": "qwen-image-3.0-pro", "trusted": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"assetType": "VIDEO",
|
||||
"models": [
|
||||
{ "modelId": "wan2.7-t2v", "trusted": true },
|
||||
{ "modelId": "wan2.7-i2v", "trusted": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 22. 查询用户是否已开通资产中心服务
|
||||
|
||||
**POST** `/zelda/api/v1/bailian/asset/checkAssetServiceSubscription`
|
||||
|
||||
查询当前用户是否已开通资产中心服务。
|
||||
|
||||
### 请求参数
|
||||
|
||||
仅需公共参数(`mainAccountUid` 必填)。
|
||||
|
||||
### 响应 data
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------- | ------- | ------------------------------------------------- |
|
||||
| enabled | Boolean | 是否已开通资产中心服务:true-已开通,false-未开通 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": "ws-xxxxx",
|
||||
"tenantId": "123456",
|
||||
"mainAccountUid": "1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import type { AssetBatchResponse } from "./types.ts";
|
||||
import {
|
||||
ASSET_API,
|
||||
ASSET_ID_FLAG,
|
||||
callAssetApi,
|
||||
dryRunPayload,
|
||||
validateAssetIds,
|
||||
} from "./utils.ts";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
...ASSET_ID_FLAG,
|
||||
permanent: {
|
||||
type: "switch",
|
||||
description: "Permanently delete assets (cannot be restored)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* `bl asset-center delete` — 删除资产(默认软删到回收站)。
|
||||
*
|
||||
* 软删可恢复;--permanent 为永久删除。支持重复 --id(单次最多 100 个)。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Delete assets (soft delete to recycle bin by default)",
|
||||
auth: "console",
|
||||
usageArgs: "--id <asset-id> [--id <asset-id>...] [flags]",
|
||||
flags: DELETE_FLAGS,
|
||||
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002", "--id asset-001 --permanent"],
|
||||
validate(flags) {
|
||||
return validateAssetIds(flags.id);
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const assetIdList = flags.id;
|
||||
const deleteType = flags.permanent ? "PERMANENT_DELETE" : "SOFT_DELETE";
|
||||
const body = { assetIdList, deleteType };
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
dryRunPayload(settings, identity.binName, ASSET_API.batchDeleteAsset, body),
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetBatchResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.batchDeleteAsset,
|
||||
body,
|
||||
);
|
||||
|
||||
const verb = flags.permanent ? "Permanently deleted" : "Deleted";
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(`${verb} ${data.affectedCount ?? assetIdList.length} asset(s).`);
|
||||
} else {
|
||||
emitResult(
|
||||
{ affected_count: data.affectedCount ?? assetIdList.length, delete_type: deleteType },
|
||||
format,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
|
||||
import type { AssetDownloadResponse } from "./types.ts";
|
||||
import { ASSET_API, callAssetApi, dryRunPayload } from "./utils.ts";
|
||||
|
||||
const DOWNLOAD_FLAGS = {
|
||||
id: {
|
||||
type: "string",
|
||||
valueHint: "<asset-id>",
|
||||
description: "Asset ID to get download URL for",
|
||||
required: true,
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* `bl asset-center download` — 通过资产 ID 获取签名下载链接。
|
||||
*
|
||||
* 调用 batchGetAssetDownloadUrl,输出 download URL,不落盘。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Get a signed download URL for an asset by ID",
|
||||
auth: "console",
|
||||
usageArgs: "--id <asset-id>",
|
||||
flags: DOWNLOAD_FLAGS,
|
||||
exampleArgs: ["--id asset-001", "--id asset-001 --output json", "--id asset-001 --quiet"],
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const assetId = flags.id;
|
||||
const body = { assetIdList: [assetId] };
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
asset_id: assetId,
|
||||
action: "download",
|
||||
...dryRunPayload(settings, identity.binName, ASSET_API.batchGetAssetDownloadUrl, body),
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetDownloadResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.batchGetAssetDownloadUrl,
|
||||
body,
|
||||
);
|
||||
|
||||
const url = data.items?.[0]?.downloadUrl;
|
||||
if (!url) {
|
||||
throw new BailianError(`No download URL available for ${assetId}.`, ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ asset_id: assetId, download_url: url }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(`${padEnd("AssetId", 16)} ${assetId}`);
|
||||
emitBare(`${padEnd("DownloadUrl", 16)} ${url}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import type { AssetBatchResponse } from "./types.ts";
|
||||
import {
|
||||
ASSET_API,
|
||||
ASSET_ID_FLAG,
|
||||
callAssetApi,
|
||||
dryRunPayload,
|
||||
validateAssetIds,
|
||||
} from "./utils.ts";
|
||||
|
||||
/**
|
||||
* `bl asset-center favorite` — 收藏一个或多个资产。
|
||||
*
|
||||
* 支持重复 --id(单次最多 100 个),调用 batchFavoriteAsset。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Add assets to favorites",
|
||||
auth: "console",
|
||||
usageArgs: "--id <asset-id> [--id <asset-id>...]",
|
||||
flags: ASSET_ID_FLAG,
|
||||
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002"],
|
||||
validate(flags) {
|
||||
return validateAssetIds(flags.id);
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const assetIdList = flags.id;
|
||||
const body = { assetIdList };
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
dryRunPayload(settings, identity.binName, ASSET_API.batchFavoriteAsset, body),
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetBatchResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.batchFavoriteAsset,
|
||||
body,
|
||||
);
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(`Favorited ${data.affectedCount ?? assetIdList.length} asset(s).`);
|
||||
} else {
|
||||
emitResult({ affected_count: data.affectedCount ?? assetIdList.length }, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
|
||||
import type { AssetGetResponse } from "./types.ts";
|
||||
import { ASSET_API, callAssetApi, dryRunPayload, formatGenerateTime } from "./utils.ts";
|
||||
|
||||
const GET_FLAGS = {
|
||||
assetId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Asset ID to query",
|
||||
required: true,
|
||||
},
|
||||
includeDownloadUrl: {
|
||||
type: "switch",
|
||||
description: "Include signed download URL",
|
||||
},
|
||||
includeThumbnail: {
|
||||
type: "switch",
|
||||
description: "Include thumbnail URL",
|
||||
},
|
||||
thumbnailWidth: {
|
||||
type: "number",
|
||||
valueHint: "<px>",
|
||||
description: "Thumbnail width in pixels",
|
||||
},
|
||||
thumbnailHeight: {
|
||||
type: "number",
|
||||
valueHint: "<px>",
|
||||
description: "Thumbnail height in pixels",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* `bl asset-center get` — 按 ID 查询单个资产详情。
|
||||
*
|
||||
* 可选 --include-download-url / --include-thumbnail 获取签名 URL。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Get full details of a model-generated asset",
|
||||
auth: "console",
|
||||
usageArgs: "--asset-id <id> [flags]",
|
||||
flags: GET_FLAGS,
|
||||
exampleArgs: [
|
||||
"--asset-id asset-001",
|
||||
"--asset-id asset-001 --include-download-url --output json",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const assetId = flags.assetId;
|
||||
|
||||
const body: Record<string, unknown> = { assetId };
|
||||
if (flags.includeDownloadUrl) body.includeDownloadUrl = true;
|
||||
if (flags.includeThumbnail) body.includeThumbnail = true;
|
||||
if (flags.thumbnailWidth !== undefined) body.thumbnailWidth = flags.thumbnailWidth;
|
||||
if (flags.thumbnailHeight !== undefined) body.thumbnailHeight = flags.thumbnailHeight;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
dryRunPayload(settings, identity.binName, ASSET_API.getModelGeneratedAsset, body),
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetGetResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.getModelGeneratedAsset,
|
||||
body,
|
||||
);
|
||||
|
||||
const item = data.item;
|
||||
if (!item) {
|
||||
emitBare("Asset not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(`${padEnd("AssetId", 16)} ${item.assetId ?? "-"}`);
|
||||
emitBare(`${padEnd("Type", 16)} ${item.assetType ?? "-"}`);
|
||||
emitBare(`${padEnd("Name", 16)} ${item.assetName ?? "-"}`);
|
||||
emitBare(`${padEnd("Description", 16)} ${item.assetDescription ?? "-"}`);
|
||||
emitBare(`${padEnd("Model", 16)} ${item.modelName ?? "-"}`);
|
||||
emitBare(`${padEnd("Favorited", 16)} ${item.favorited ? "yes" : "no"}`);
|
||||
emitBare(`${padEnd("Generated", 16)} ${formatGenerateTime(item.generateTime)}`);
|
||||
if (item.downloadUrl) emitBare(`${padEnd("DownloadUrl", 16)} ${item.downloadUrl}`);
|
||||
if (item.thumbnailUrl) emitBare(`${padEnd("ThumbnailUrl", 16)} ${item.thumbnailUrl}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import type { AssetListResponse, ModelGeneratedAssetItem } from "./types.ts";
|
||||
import {
|
||||
ASSET_API,
|
||||
ASSET_LIST_FILTER_FLAGS,
|
||||
buildListFilterBody,
|
||||
callAssetApi,
|
||||
dryRunPayload,
|
||||
formatGenerateTime,
|
||||
} from "./utils.ts";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
...ASSET_LIST_FILTER_FLAGS,
|
||||
includeDownloadUrl: {
|
||||
type: "switch",
|
||||
description: "Include signed download URLs in the response",
|
||||
},
|
||||
includeThumbnail: {
|
||||
type: "switch",
|
||||
description: "Include thumbnail URLs in the response",
|
||||
},
|
||||
thumbnailWidth: {
|
||||
type: "number",
|
||||
valueHint: "<px>",
|
||||
description: "Thumbnail width in pixels",
|
||||
},
|
||||
thumbnailHeight: {
|
||||
type: "number",
|
||||
valueHint: "<px>",
|
||||
description: "Thumbnail height in pixels",
|
||||
},
|
||||
pageSize: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Results per page (default: 10, max: 100)",
|
||||
},
|
||||
nextToken: {
|
||||
type: "number",
|
||||
valueHint: "<token>",
|
||||
description: "Cursor for the next page",
|
||||
},
|
||||
preToken: {
|
||||
type: "number",
|
||||
valueHint: "<token>",
|
||||
description: "Cursor for the previous page",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
function normalizeItem(item: ModelGeneratedAssetItem) {
|
||||
return {
|
||||
asset_id: item.assetId ?? "",
|
||||
asset_type: item.assetType ?? "",
|
||||
asset_name: item.assetName ?? "",
|
||||
model_name: item.modelName ?? "",
|
||||
favorited: item.favorited ?? false,
|
||||
generate_time: item.generateTime,
|
||||
download_url: item.downloadUrl,
|
||||
thumbnail_url: item.thumbnailUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `bl asset-center list` — 分页查询模型生成资产列表。
|
||||
*
|
||||
* 支持类型/模型/关键词/收藏/回收站/OSS 同步状态/时间范围筛选,以及
|
||||
* --next-token / --pre-token 游标翻页;可选返回签名下载链接与缩略图 URL。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "List model-generated assets with filters and cursor pagination",
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
flags: LIST_FLAGS,
|
||||
exampleArgs: [
|
||||
"",
|
||||
"--type IMAGE --model qwen-image-3.0",
|
||||
"--favorited --page-size 20",
|
||||
"--recycle-bin",
|
||||
"--keyword landscape --output json",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const pageSize = flags.pageSize ?? 10;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
...buildListFilterBody(flags),
|
||||
pageSize,
|
||||
};
|
||||
if (flags.includeDownloadUrl) body.includeDownloadUrl = true;
|
||||
if (flags.includeThumbnail) body.includeThumbnail = true;
|
||||
if (flags.thumbnailWidth !== undefined) body.thumbnailWidth = flags.thumbnailWidth;
|
||||
if (flags.thumbnailHeight !== undefined) body.thumbnailHeight = flags.thumbnailHeight;
|
||||
if (flags.nextToken !== undefined) body.nextToken = flags.nextToken;
|
||||
if (flags.preToken !== undefined) body.preToken = flags.preToken;
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
dryRunPayload(settings, identity.binName, ASSET_API.listModelGeneratedAsset, body),
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetListResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.listModelGeneratedAsset,
|
||||
body,
|
||||
);
|
||||
|
||||
const items = (data.dataList ?? []).map(normalizeItem);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
items,
|
||||
pre_token: data.preToken,
|
||||
next_token: data.nextToken,
|
||||
has_next: data.hasNext,
|
||||
has_pre: data.hasPre,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
emitBare("No assets found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ["ASSET_ID", "TYPE", "NAME", "MODEL", "FAVORITED", "GENERATED"];
|
||||
const rows = items.map((item) => [
|
||||
item.asset_id,
|
||||
item.asset_type,
|
||||
item.asset_name,
|
||||
item.model_name,
|
||||
item.favorited ? "yes" : "-",
|
||||
formatGenerateTime(item.generate_time),
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (data.hasPre) parts.push("has previous page");
|
||||
if (data.hasNext) parts.push(`next token: ${data.nextToken}`);
|
||||
if (parts.length > 0) emitBare(`\n${parts.join("; ")}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
|
||||
import type { AssetCountResponse } from "./types.ts";
|
||||
import {
|
||||
ASSET_API,
|
||||
ASSET_LIST_FILTER_FLAGS,
|
||||
buildListFilterBody,
|
||||
callAssetApi,
|
||||
dryRunPayload,
|
||||
} from "./utils.ts";
|
||||
|
||||
const STATS_FLAGS = {
|
||||
...ASSET_LIST_FILTER_FLAGS,
|
||||
syncFailed: {
|
||||
type: "switch",
|
||||
description: "Also count assets with failed OSS sync",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/**
|
||||
* `bl asset-center stats` — 按类型统计模型生成资产数量。
|
||||
*
|
||||
* 复用 list 的筛选条件;--sync-failed 额外统计 OSS 同步失败的资产数。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Count model-generated assets by type",
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
flags: STATS_FLAGS,
|
||||
exampleArgs: ["", "--sync-failed", "--type IMAGE --output json"],
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const body = buildListFilterBody(flags);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
dryRunPayload(settings, identity.binName, ASSET_API.countModelGeneratedAsset, body),
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetCountResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.countModelGeneratedAsset,
|
||||
body,
|
||||
);
|
||||
|
||||
let syncFailedCount: number | undefined;
|
||||
if (flags.syncFailed) {
|
||||
const failed = await callAssetApi<AssetCountResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.countModelGeneratedAsset,
|
||||
{ ...body, syncOssDataStatus: "SYNC_FAILED" },
|
||||
);
|
||||
syncFailedCount = failed.totalCount ?? 0;
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
total_count: data.totalCount ?? 0,
|
||||
image_count: data.imageCount ?? 0,
|
||||
video_count: data.videoCount ?? 0,
|
||||
audio_count: data.audioCount ?? 0,
|
||||
...(syncFailedCount !== undefined ? { sync_failed_count: syncFailedCount } : {}),
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(`${padEnd("Total", 14)} ${data.totalCount ?? 0}`);
|
||||
emitBare(`${padEnd("Image", 14)} ${data.imageCount ?? 0}`);
|
||||
emitBare(`${padEnd("Video", 14)} ${data.videoCount ?? 0}`);
|
||||
emitBare(`${padEnd("Audio", 14)} ${data.audioCount ?? 0}`);
|
||||
if (syncFailedCount !== undefined) {
|
||||
emitBare(`${padEnd("Sync failed", 14)} ${syncFailedCount}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, padEnd } from "bailian-cli-runtime";
|
||||
import type { AssetStorageQuotaResponse } from "./types.ts";
|
||||
import { ASSET_API, callAssetApi, dryRunPayload, formatStorageBytes } from "./utils.ts";
|
||||
|
||||
/**
|
||||
* `bl asset-center storage` — 查看存储配额、已用容量与超额计费说明。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "View storage quota, usage, and overage pricing",
|
||||
auth: "console",
|
||||
usageArgs: "[flags]",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const { settings, identity } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(dryRunPayload(settings, identity.binName, ASSET_API.getStorageQuota, {}), format);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetStorageQuotaResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.getStorageQuota,
|
||||
{},
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
used_storage_size: data.usedStorageSize,
|
||||
free_storage_quota: data.freeStorageQuota,
|
||||
extra_storage_price: data.extraStoragePrice,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(`${padEnd("Used", 14)} ${formatStorageBytes(data.usedStorageSize)}`);
|
||||
emitBare(`${padEnd("Free quota", 14)} ${formatStorageBytes(data.freeStorageQuota)}`);
|
||||
emitBare(`${padEnd("Overage", 14)} ${data.extraStoragePrice ?? "-"}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export type AssetType = "IMAGE" | "VIDEO" | "AUDIO";
|
||||
|
||||
export type AssetDeleteStatus = "NORMAL" | "SOFT_DELETED" | "PERMANENTLY_DELETED";
|
||||
|
||||
export type AssetSyncOssStatus = "NOT_SYNCED" | "IN_SYNCING" | "SYNC_SUCCESS" | "SYNC_FAILED";
|
||||
|
||||
export type AssetDeleteType = "SOFT_DELETE" | "PERMANENT_DELETE";
|
||||
|
||||
export interface AssetHttpBaseRequest {
|
||||
workspace: string;
|
||||
tenantId?: string;
|
||||
mainAccountUid?: string;
|
||||
apiSource?: string;
|
||||
}
|
||||
|
||||
export interface ModelGeneratedAssetItem {
|
||||
id?: number;
|
||||
assetId?: string;
|
||||
assetType?: string;
|
||||
assetName?: string;
|
||||
assetDescription?: string;
|
||||
favorited?: boolean;
|
||||
assetSize?: number;
|
||||
modelType?: string;
|
||||
modelName?: string;
|
||||
deleteStatus?: string;
|
||||
syncOssDataStatus?: string;
|
||||
generateTime?: number;
|
||||
downloadUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
gmtCreate?: string;
|
||||
gmtModified?: string;
|
||||
}
|
||||
|
||||
export interface AssetListResponse {
|
||||
dataList?: ModelGeneratedAssetItem[];
|
||||
preToken?: number;
|
||||
nextToken?: number;
|
||||
hasNext?: boolean;
|
||||
hasPre?: boolean;
|
||||
}
|
||||
|
||||
export interface AssetGetResponse {
|
||||
item?: ModelGeneratedAssetItem;
|
||||
}
|
||||
|
||||
export interface AssetBatchResponse {
|
||||
success?: boolean;
|
||||
affectedCount?: number;
|
||||
}
|
||||
|
||||
export interface AssetDownloadUrlItem {
|
||||
assetId?: string;
|
||||
downloadUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface AssetDownloadResponse {
|
||||
items?: AssetDownloadUrlItem[];
|
||||
}
|
||||
|
||||
export interface AssetCountResponse {
|
||||
imageCount?: number;
|
||||
videoCount?: number;
|
||||
audioCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export interface AssetStorageQuotaResponse {
|
||||
freeStorageQuota?: number;
|
||||
usedStorageSize?: number;
|
||||
extraStoragePrice?: string;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import type { AssetBatchResponse } from "./types.ts";
|
||||
import {
|
||||
ASSET_API,
|
||||
ASSET_ID_FLAG,
|
||||
callAssetApi,
|
||||
dryRunPayload,
|
||||
validateAssetIds,
|
||||
} from "./utils.ts";
|
||||
|
||||
/**
|
||||
* `bl asset-center unfavorite` — 取消收藏一个或多个资产。
|
||||
*
|
||||
* 支持重复 --id(单次最多 100 个),调用 batchUnfavoriteAsset。
|
||||
*/
|
||||
export default defineCommand({
|
||||
description: "Remove assets from favorites",
|
||||
auth: "console",
|
||||
usageArgs: "--id <asset-id> [--id <asset-id>...]",
|
||||
flags: ASSET_ID_FLAG,
|
||||
exampleArgs: ["--id asset-001", "--id asset-001 --id asset-002"],
|
||||
validate(flags) {
|
||||
return validateAssetIds(flags.id);
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, identity, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const assetIdList = flags.id;
|
||||
const body = { assetIdList };
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
dryRunPayload(settings, identity.binName, ASSET_API.batchUnfavoriteAsset, body),
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await callAssetApi<AssetBatchResponse>(
|
||||
ctx.client,
|
||||
settings,
|
||||
identity.binName,
|
||||
ASSET_API.batchUnfavoriteAsset,
|
||||
body,
|
||||
);
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(`Unfavorited ${data.affectedCount ?? assetIdList.length} asset(s).`);
|
||||
} else {
|
||||
emitResult({ affected_count: data.affectedCount ?? assetIdList.length }, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
effectiveConsoleGatewayConfig,
|
||||
type Client,
|
||||
type FlagsDef,
|
||||
type ParsedFlags,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
import type { AssetHttpBaseRequest, AssetSyncOssStatus, AssetType } from "./types.ts";
|
||||
|
||||
const ASSET_SERVICE = "dashscopeModel";
|
||||
const ASSET_BASE = "/zelda/api/v1/bailian/asset";
|
||||
export const MAX_ASSET_BATCH_SIZE = 100;
|
||||
|
||||
export const ASSET_API = {
|
||||
listModelGeneratedAsset: assetApi("listModelGeneratedAsset"),
|
||||
getModelGeneratedAsset: assetApi("getModelGeneratedAsset"),
|
||||
batchFavoriteAsset: assetApi("batchFavoriteAsset"),
|
||||
batchUnfavoriteAsset: assetApi("batchUnfavoriteAsset"),
|
||||
batchDeleteAsset: assetApi("batchDeleteAsset"),
|
||||
batchGetAssetDownloadUrl: assetApi("batchGetAssetDownloadUrl"),
|
||||
countModelGeneratedAsset: assetApi("countModelGeneratedAsset"),
|
||||
getStorageQuota: assetApi("getStorageQuota"),
|
||||
} as const;
|
||||
|
||||
export const ASSET_ID_FLAG = {
|
||||
id: {
|
||||
type: "array",
|
||||
valueHint: "<asset-id>",
|
||||
description: "Asset ID(s) to operate on (repeatable, max 100)",
|
||||
required: true,
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export const ASSET_LIST_FILTER_FLAGS = {
|
||||
type: {
|
||||
type: "string",
|
||||
valueHint: "<type>",
|
||||
description: "Asset type: IMAGE, VIDEO, or AUDIO",
|
||||
choices: ["IMAGE", "VIDEO", "AUDIO"] as const,
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Filter by model name",
|
||||
},
|
||||
keyword: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Filter by asset name (substring match)",
|
||||
},
|
||||
favorited: {
|
||||
type: "switch",
|
||||
description: "Show or count only favorited assets",
|
||||
},
|
||||
recycleBin: {
|
||||
type: "switch",
|
||||
description: "Show or count soft-deleted assets (recycle bin)",
|
||||
},
|
||||
syncStatus: {
|
||||
type: "string",
|
||||
valueHint: "<status>",
|
||||
description: "OSS sync status filter",
|
||||
choices: ["NOT_SYNCED", "IN_SYNCING", "SYNC_SUCCESS", "SYNC_FAILED"] as const,
|
||||
},
|
||||
beginTime: {
|
||||
type: "string",
|
||||
valueHint: "<datetime>",
|
||||
description: "Filter by generate time start (ISO_LOCAL_DATE_TIME)",
|
||||
},
|
||||
endTime: {
|
||||
type: "string",
|
||||
valueHint: "<datetime>",
|
||||
description: "Filter by generate time end (ISO_LOCAL_DATE_TIME)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
type AssetListFilterFlags = ParsedFlags<typeof ASSET_LIST_FILTER_FLAGS>;
|
||||
|
||||
function assetApi(action: string): string {
|
||||
return `zeldaHttp.${ASSET_SERVICE}.${ASSET_BASE}/${action}`;
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === "object" && !Array.isArray(val)) return val as Record<string, unknown>;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractAssetResponse<T>(result: unknown): T {
|
||||
const raw = result as Record<string, unknown>;
|
||||
const data = getNestedRecord(raw, "data");
|
||||
if (!data) {
|
||||
throw new BailianError("Unexpected empty response from asset API.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const dataV2 = getNestedRecord(data, "DataV2");
|
||||
const payload = dataV2
|
||||
? (getNestedRecord(getNestedRecord(dataV2, "data") ?? dataV2, "data") ??
|
||||
getNestedRecord(dataV2, "data") ??
|
||||
dataV2)
|
||||
: (getNestedRecord(data, "data") ?? data);
|
||||
|
||||
if (payload.success === false) {
|
||||
const message =
|
||||
typeof payload.message === "string" && payload.message.length > 0
|
||||
? payload.message
|
||||
: typeof payload.code === "string"
|
||||
? payload.code
|
||||
: "Asset API request failed.";
|
||||
throw new BailianError(message, ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
if (payload.data !== undefined) {
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function requireWorkspaceId(settings: Settings, binName: string): string {
|
||||
if (settings.workspaceId) return settings.workspaceId;
|
||||
|
||||
throw new BailianError(
|
||||
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${binName} config set workspace_id <id>\`.`,
|
||||
ExitCode.GENERAL,
|
||||
`Run \`${binName} workspace list\` to view available workspaces.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildBaseRequest(settings: Settings, binName: string): AssetHttpBaseRequest {
|
||||
// workspace 由 CLI 注入;tenantId / mainAccountUid 由 Console 网关从登录 session 自动填充,
|
||||
// CLI 侧无需也不应手动解析阿里云账号 ID。
|
||||
return {
|
||||
workspace: requireWorkspaceId(settings, binName),
|
||||
apiSource: "CLI",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildListFilterBody(flags: AssetListFilterFlags): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
if (flags.type) body.assetType = flags.type as AssetType;
|
||||
if (flags.model) body.modelName = flags.model;
|
||||
if (flags.keyword) body.assetName = flags.keyword;
|
||||
if (flags.favorited) body.favorited = true;
|
||||
if (flags.recycleBin) {
|
||||
body.deleteStatus = "SOFT_DELETED";
|
||||
} else {
|
||||
body.deleteStatus = "NORMAL";
|
||||
}
|
||||
if (flags.syncStatus) body.syncOssDataStatus = flags.syncStatus as AssetSyncOssStatus;
|
||||
if (flags.beginTime) body.beginTime = flags.beginTime;
|
||||
if (flags.endTime) body.endTime = flags.endTime;
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function validateAssetIds(ids: string[] | undefined): string | undefined {
|
||||
if (!ids || ids.length === 0) {
|
||||
return "At least one --id is required.";
|
||||
}
|
||||
if (ids.length > MAX_ASSET_BATCH_SIZE) {
|
||||
return `At most ${MAX_ASSET_BATCH_SIZE} asset IDs are allowed per request.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function callAssetApi<T>(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
binName: string,
|
||||
api: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const payload = { ...buildBaseRequest(settings, binName), ...body };
|
||||
const raw = await client.console(api, payload);
|
||||
return extractAssetResponse<T>(raw);
|
||||
}
|
||||
|
||||
export function dryRunPayload(
|
||||
settings: Settings,
|
||||
binName: string,
|
||||
api: string,
|
||||
body: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
api,
|
||||
data: { ...buildBaseRequest(settings, binName), ...body },
|
||||
...effectiveConsoleGatewayConfig(settings),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatGenerateTime(ts?: number): string {
|
||||
if (ts == null) return "-";
|
||||
return new Date(ts).toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
|
||||
export function formatStorageBytes(bytes?: number): string {
|
||||
if (bytes == null) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
|
||||
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
|
||||
|
||||
/** Read-only auth snapshot the config UI account widget renders. bl stores no
|
||||
* user profile (name/avatar), so this exposes only which credential domains
|
||||
* resolve, the console region/site, and a masked token. */
|
||||
export interface AuthUiStatus {
|
||||
authenticated: boolean;
|
||||
methods: { apiKey: boolean; console: boolean; openapi: boolean };
|
||||
primary: "console" | "apiKey" | "openapi" | null;
|
||||
region?: string;
|
||||
site?: "domestic" | "international";
|
||||
masked?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth capability surface the config UI is allowed to use. All `authStore`
|
||||
* access is kept inside this module (commands/auth/**), which the lint boundary
|
||||
* permits; commands/config/** consumes only this opaque bridge and never
|
||||
* touches `authStore` directly.
|
||||
*/
|
||||
export interface AuthUiBridge {
|
||||
status(): AuthUiStatus;
|
||||
/** Start browser-based console login (fire-and-forget; UI polls status). */
|
||||
startConsoleLogin(): void;
|
||||
/** Clear all stored credentials. Returns whether anything changed. */
|
||||
logout(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Build the bridge from a command context (identity/settings/authStore). */
|
||||
export function makeAuthUiBridge(ctx: {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
authStore: AuthStore;
|
||||
}): AuthUiBridge {
|
||||
const { identity, settings, authStore } = ctx;
|
||||
return {
|
||||
status() {
|
||||
const a = authStore.describe();
|
||||
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
|
||||
let masked: string | undefined;
|
||||
if (a.console) masked = maskToken(a.console.token);
|
||||
else if (a.apiKey) masked = maskToken(a.apiKey.token);
|
||||
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
|
||||
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
|
||||
return {
|
||||
authenticated: methods.apiKey || methods.console || methods.openapi,
|
||||
methods,
|
||||
primary,
|
||||
region: a.console?.region,
|
||||
site: a.console?.site,
|
||||
masked,
|
||||
};
|
||||
},
|
||||
startConsoleLogin() {
|
||||
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
|
||||
// Mirror the CLI (`bl auth login --console`): request an api_key from the
|
||||
// console only when one isn't already stored, so a first console login in
|
||||
// the config UI also provisions the model api_key (not just access_token).
|
||||
const hasApiKey = !!authStore.stored().apiKey;
|
||||
// runConsoleLogin opens the browser and runs its own callback server
|
||||
// (up to 15 min). We don't await it — the config UI polls the status
|
||||
// endpoint to detect completion. Errors are logged, not surfaced.
|
||||
void runConsoleLogin(
|
||||
origin,
|
||||
{ identity, settings, authStore },
|
||||
{
|
||||
needApiKey: !hasApiKey,
|
||||
},
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`console login failed: ${msg}\n`);
|
||||
});
|
||||
},
|
||||
logout() {
|
||||
return authStore.logout("all");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function validateAndPersistApiKey(
|
||||
const persistBaseUrl = profile.persistBaseUrl
|
||||
? normalizeModelBaseUrl(profile.persistBaseUrl)
|
||||
: undefined;
|
||||
const validationModel = profile.defaultTextModel || "qwen3.7-max";
|
||||
const validationModel = "qwen3.8-max";
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
@@ -68,7 +68,6 @@ export async function validateAndPersistApiKey(
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
enable_thinking: validationModel === "qwen3.8-max-preview",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ export default defineCommand({
|
||||
usageArgs:
|
||||
"--api-key <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
|
||||
flags: {
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "Model API key to store",
|
||||
},
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
|
||||
@@ -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,153 @@
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Decoder for the obfuscated API key ("o1_…") produced by the Model Studio web
|
||||
* console. Ported verbatim from the frontend `encodeTokenPlanKey` counterpart:
|
||||
* token = "o1_" + salt(6) + feistel-obfuscated payload + crc32 checksum(6),
|
||||
* all over a 65-character alphabet. Pure logic, no dependencies; the CLI only
|
||||
* ever needs the decode direction.
|
||||
*/
|
||||
|
||||
const TOKEN_PREFIX = "o1_";
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
|
||||
const ALPHABET_SIZE = ALPHABET.length;
|
||||
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
|
||||
const KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const SALT_LENGTH = 6;
|
||||
const CHECKSUM_LENGTH = 6;
|
||||
const FEISTEL_ROUNDS = 8;
|
||||
|
||||
function invalidCredential(): BailianError {
|
||||
return new BailianError(
|
||||
"Invalid obfuscated API key.",
|
||||
ExitCode.USAGE,
|
||||
'--key expects the obfuscated key copied from the web console (starts with "o1_").',
|
||||
);
|
||||
}
|
||||
|
||||
function toDigits(value: string): number[] {
|
||||
const digits: number[] = [];
|
||||
for (const character of value) {
|
||||
const digit = ALPHABET_INDEX.get(character);
|
||||
if (digit === undefined) throw invalidCredential();
|
||||
digits.push(digit);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function fromDigits(digits: number[]): string {
|
||||
return digits.map((digit) => ALPHABET[digit]).join("");
|
||||
}
|
||||
|
||||
function mixState(state: number, value: number): number {
|
||||
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
function nextState(state: number): number {
|
||||
let next = state >>> 0;
|
||||
next ^= next << 13;
|
||||
next ^= next >>> 17;
|
||||
next ^= next << 5;
|
||||
return next >>> 0;
|
||||
}
|
||||
|
||||
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
|
||||
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
|
||||
|
||||
state = mixState(state, right.length);
|
||||
state = mixState(state, length);
|
||||
for (const character of salt) {
|
||||
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
|
||||
}
|
||||
for (const digit of right) {
|
||||
state = mixState(state, digit + 1);
|
||||
}
|
||||
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 0x85ebca6b) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 0xc2b2ae35) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = state >>> 0 || 0x6d2b79f5;
|
||||
|
||||
const mask: number[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
|
||||
state = nextState(state);
|
||||
mask.push(state % ALPHABET_SIZE);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function deobfuscatePayload(payload: string, salt: string): string {
|
||||
const digits = toDigits(payload);
|
||||
const midpoint = Math.floor(digits.length / 2);
|
||||
let left = digits.slice(0, midpoint);
|
||||
let right = digits.slice(midpoint);
|
||||
|
||||
for (let round = FEISTEL_ROUNDS - 1; round >= 0; round -= 1) {
|
||||
const previousRight = left;
|
||||
const mask = createRoundMask(previousRight, salt, round, right.length);
|
||||
const previousLeft = right.map(
|
||||
(digit, index) => (digit - mask[index] + ALPHABET_SIZE) % ALPHABET_SIZE,
|
||||
);
|
||||
left = previousLeft;
|
||||
right = previousRight;
|
||||
}
|
||||
|
||||
return fromDigits([...left, ...right]);
|
||||
}
|
||||
|
||||
function crc32(value: string): number {
|
||||
let checksum = 0xffffffff;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
checksum ^= value.charCodeAt(index);
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
const mask = -(checksum & 1);
|
||||
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
|
||||
}
|
||||
}
|
||||
return (checksum ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function encodeBase65Number(value: number, length: number): string {
|
||||
let remaining = value >>> 0;
|
||||
const encoded = Array<string>(length).fill(ALPHABET[0]);
|
||||
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
|
||||
remaining = Math.floor(remaining / ALPHABET_SIZE);
|
||||
}
|
||||
if (remaining !== 0) throw invalidCredential();
|
||||
return encoded.join("");
|
||||
}
|
||||
|
||||
function validateSalt(salt: string): void {
|
||||
if (salt.length !== SALT_LENGTH || !KEY_PATTERN.test(salt)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an "o1_…" obfuscated token back into the plain API key. */
|
||||
export function decodeTokenPlanKey(token: string): string {
|
||||
const minimumLength = TOKEN_PREFIX.length + SALT_LENGTH + CHECKSUM_LENGTH + 1;
|
||||
if (token.length < minimumLength || !token.startsWith(TOKEN_PREFIX)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
|
||||
const body = token.slice(TOKEN_PREFIX.length);
|
||||
if (!KEY_PATTERN.test(body)) throw invalidCredential();
|
||||
|
||||
const salt = body.slice(0, SALT_LENGTH);
|
||||
const payload = body.slice(SALT_LENGTH, -CHECKSUM_LENGTH);
|
||||
const checksum = body.slice(-CHECKSUM_LENGTH);
|
||||
validateSalt(salt);
|
||||
if (!payload) throw invalidCredential();
|
||||
|
||||
const apiKey = deobfuscatePayload(payload, salt);
|
||||
if (!KEY_PATTERN.test(apiKey)) throw invalidCredential();
|
||||
|
||||
const expectedChecksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
|
||||
if (checksum !== expectedChecksum) throw invalidCredential();
|
||||
return apiKey;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
import { decodeTokenPlanKey } from "./decode-key.ts";
|
||||
import { resolveRegionBaseUrl } from "./writers/utils.ts";
|
||||
|
||||
const FLAGS = {
|
||||
agent: {
|
||||
@@ -11,30 +13,76 @@ const FLAGS = {
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "API base URL",
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
valueHint: "<region>",
|
||||
description:
|
||||
"Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "API key",
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
valueHint: "<encoded>",
|
||||
description:
|
||||
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
contextWindow: {
|
||||
type: "number",
|
||||
valueHint: "<tokens>",
|
||||
description: "OpenClaw only: model context window in tokens (default: 256000)",
|
||||
},
|
||||
wireApi: {
|
||||
type: "string",
|
||||
valueHint: "<api>",
|
||||
description:
|
||||
'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0',
|
||||
choices: ["chat", "responses"],
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Configure a coding agent to use DashScope API",
|
||||
auth: "none",
|
||||
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
|
||||
usageArgs:
|
||||
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: [
|
||||
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
|
||||
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
],
|
||||
validate(flags) {
|
||||
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
|
||||
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
|
||||
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
|
||||
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { baseUrl, apiKey, model } = flags;
|
||||
const { model, contextWindow, wireApi } = flags;
|
||||
// --region is a Token Plan convenience: convert it into a base URL and use
|
||||
// it exactly as --base-url would be.
|
||||
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
|
||||
// --key carries the web console's obfuscated form; decode it up front so
|
||||
// even --dry-run validates the token.
|
||||
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -59,13 +107,22 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const params: WriteParams = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
contextWindow,
|
||||
wireApi,
|
||||
};
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
emitBare(`${agentDef.label} configured successfully.`);
|
||||
for (const path of summary.paths) emitBare(` Written: ${path}`);
|
||||
emitBare(` ${summary.nextStep}`);
|
||||
for (const warning of summary.warnings ?? []) {
|
||||
process.stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
resolveClaudeCodeBaseUrl,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** Fill a tier/default model env only when the user has not set it yet. */
|
||||
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
|
||||
const current = env[key];
|
||||
if (current === undefined || current.trim() === "") {
|
||||
env[key] = model;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
||||
const settingsPath = join(configDir, "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
const warnings: string[] = [];
|
||||
|
||||
const resolved = resolveClaudeCodeBaseUrl(baseUrl);
|
||||
if (resolved.rewrittenFrom) {
|
||||
warnings.push(
|
||||
`Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` +
|
||||
`(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`,
|
||||
);
|
||||
}
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
// the endpoint; the model tier vars force every tier onto the chosen model.
|
||||
// the Anthropic-compatible endpoint; primary model always updates, while
|
||||
// tier/subagent defaults are filled only when absent so existing setups
|
||||
// (e.g. Token Plan Haiku/Subagent splits) are not wiped.
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.ANTHROPIC_BASE_URL = resolved.url;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
// AUTH_TOKEN and API_KEY are mutually exclusive credential fields — drop a
|
||||
// stale ANTHROPIC_API_KEY so it cannot shadow the token we just wrote.
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
|
||||
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model);
|
||||
settings.env = env;
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
@@ -32,6 +62,7 @@ export default {
|
||||
return {
|
||||
paths: [settingsPath, onboardingPath],
|
||||
nextStep: "Run `claude` to start using Claude Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -8,8 +8,9 @@ const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, wireApi: wireApiParam }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
const warnings: string[] = [];
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
@@ -25,8 +26,19 @@ export default {
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
// wire_api — current Codex releases only load `wire_api = "responses"`
|
||||
// ("chat" is rejected at config load, see openai/codex discussion #7782).
|
||||
// "chat" remains an explicit opt-in for users pinned to legacy Codex
|
||||
// <= 0.80.0 (the Model Studio path for models without Responses support).
|
||||
const wireApi = wireApiParam === "chat" ? "chat" : "responses";
|
||||
if (wireApi === "chat") {
|
||||
warnings.push(
|
||||
'Current Codex releases refuse to load `wire_api = "chat"`; ' +
|
||||
"only use --wire-api chat with legacy Codex <= 0.80.0 " +
|
||||
"(e.g. `npm install -g @openai/codex@0.80.0`).",
|
||||
);
|
||||
}
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
@@ -34,14 +46,17 @@ export default {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
wire_api: "responses",
|
||||
// env_key is the official-doc credential mechanism: Codex resolves the
|
||||
// key from the OPENAI_API_KEY env var, falling back to auth.json below.
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: wireApi,
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
@@ -51,6 +66,7 @@ export default {
|
||||
return {
|
||||
paths: [configPath, authPath],
|
||||
nextStep: "Run `codex` to start using Codex with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
@@ -22,26 +20,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
// Official Model Studio doc shape: a single flat `model` block holding the
|
||||
// active endpoint + credentials. `api_mode: anthropic_messages` is required
|
||||
// for /apps/anthropic endpoints; for the OpenAI-compatible endpoint the
|
||||
// doc says to omit api_mode entirely (chat completions is the default).
|
||||
const block: Record<string, unknown> = {
|
||||
default: model,
|
||||
provider: "custom",
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
if (isAnthropicEndpoint(baseUrl)) block.api_mode = "anthropic_messages";
|
||||
config.model = block;
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
|
||||
@@ -2,20 +2,36 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
// Safe default when --context-window is not given: most Model Studio models
|
||||
// offer ≥256K context; users can raise it per model via the flag.
|
||||
const DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
|
||||
const PROVIDER_ID = "bailian-cli";
|
||||
|
||||
function readPrimary(defaults: Record<string, unknown>): string | undefined {
|
||||
const model = defaults.model;
|
||||
if (!model || typeof model !== "object") return undefined;
|
||||
const primary = (model as Record<string, unknown>).primary;
|
||||
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, contextWindow }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const warnings: string[] = [];
|
||||
const modelRef = `${PROVIDER_ID}/${model}`;
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
// models.providers["bailian-cli"]
|
||||
// models.providers["bailian-cli"] — upsert without removing other providers
|
||||
// (e.g. an existing working bailian-token-plan setup).
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
providers[PROVIDER_ID] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
@@ -23,18 +39,35 @@ export default {
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
// agents.defaults — register the model in the allow-list. Only set primary
|
||||
// when unset, or when primary already points at bailian-cli (reconfigure).
|
||||
// Never steal primary away from another provider such as bailian-token-plan.
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
|
||||
allowedModels[modelRef] = allowedModels[modelRef] ?? {};
|
||||
defaults.models = allowedModels;
|
||||
|
||||
const existingPrimary = readPrimary(defaults);
|
||||
if (!existingPrimary) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else {
|
||||
warnings.push(
|
||||
`Left existing primary model unchanged ("${existingPrimary}"). ` +
|
||||
`Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`,
|
||||
);
|
||||
}
|
||||
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
@@ -42,7 +75,9 @@ export default {
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
|
||||
nextStep:
|
||||
"Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
// opencode.json is JSONC — tolerate comments and trailing commas on read.
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
const config = readJsonc(configPath);
|
||||
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
|
||||
@@ -2,53 +2,110 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
const ENV_KEY = "DASHSCOPE_API_KEY";
|
||||
|
||||
function displayName(model: string): string {
|
||||
return `[Bailian] ${model}`;
|
||||
}
|
||||
|
||||
/** Entries we previously wrote, or still own via envKey / display brand. */
|
||||
function isBailianCliEntry(entry: Record<string, unknown>): boolean {
|
||||
if (entry.envKey === ENV_KEY) return true;
|
||||
const name = typeof entry.name === "string" ? entry.name : "";
|
||||
return name === "bailian-cli" || name.startsWith("[Bailian]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the model entry `name` and the env var name.
|
||||
* therefore lives in the env var name (`BAILIAN_CLI_API_KEY`) and the display
|
||||
* label (`[Bailian] …`); Qwen Code keys models by id (+ baseUrl), never by name.
|
||||
*
|
||||
* Qwen Code does not support duplicate model `id`s (only the first loads), so
|
||||
* we must never overwrite a pre-existing Token Plan / third-party entry that
|
||||
* shares the same id.
|
||||
*
|
||||
* Credentials are written to BOTH `env` (via the entry's `envKey`) and
|
||||
* `security.auth` — the resolver reads `security.auth.apiKey/baseUrl` as a
|
||||
* lower-priority layer, which stops a stray system `OPENAI_API_KEY` from being
|
||||
* picked up when the provider→envKey path does not resolve first. The active
|
||||
* `model` also carries its `baseUrl`, as Qwen Code requires to disambiguate
|
||||
* same-id providers.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".qwen", "settings.json");
|
||||
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
|
||||
const warnings: string[] = [];
|
||||
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// $version — Qwen Code v3 settings schema (official Model Studio doc shape).
|
||||
settings.$version = 3;
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
// Qwen Code treats settings.json `env` as lowest priority; a process/shell
|
||||
// value for the same key wins and can make the first launch fail.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
|
||||
const processEnvValue = process.env[ENV_KEY];
|
||||
if (processEnvValue !== undefined && processEnvValue !== apiKey) {
|
||||
warnings.push(
|
||||
`Shell/environment ${ENV_KEY} is set and overrides settings.json. ` +
|
||||
`Unset it (e.g. \`unset ${ENV_KEY}\`) so the key written here takes effect.`,
|
||||
);
|
||||
}
|
||||
|
||||
// modelProviders[<protocol>] — upsert only bailian-cli-owned entries.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = "bailian-cli";
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model);
|
||||
const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model);
|
||||
|
||||
if (owned) {
|
||||
owned.baseUrl = baseUrl;
|
||||
owned.envKey = ENV_KEY;
|
||||
const currentName = typeof owned.name === "string" ? owned.name.trim() : "";
|
||||
if (!currentName || currentName === "bailian-cli") owned.name = displayName(model);
|
||||
} else if (conflicting) {
|
||||
const existingName =
|
||||
typeof conflicting.name === "string" && conflicting.name.length > 0
|
||||
? conflicting.name
|
||||
: String(conflicting.id);
|
||||
warnings.push(
|
||||
`Model id "${model}" already exists as "${existingName}"; left unchanged ` +
|
||||
`(Qwen Code loads only the first entry per id). Remove or rename that ` +
|
||||
`entry if you want bailian-cli to own this model.`,
|
||||
);
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
entries.push({
|
||||
id: model,
|
||||
name: displayName(model),
|
||||
baseUrl,
|
||||
envKey: ENV_KEY,
|
||||
});
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
// security.auth — select the protocol AND keep credentials as a fallback
|
||||
// layer (see the file-level note): without this, a stray system
|
||||
// OPENAI_API_KEY can win when the provider→envKey lookup does not resolve.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
// model — active model. baseUrl MUST be written alongside name; Qwen Code
|
||||
// uses it to disambiguate same-id providers, and omitting it can misroute
|
||||
// to a different entry (and thus a different credential).
|
||||
settings.model = { name: model, baseUrl };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
@@ -56,6 +113,7 @@ export default {
|
||||
return {
|
||||
paths: [settingsPath],
|
||||
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** OpenClaw model entry context window (tokens). */
|
||||
contextWindow?: number;
|
||||
/** Codex provider wire protocol: "responses" or "chat". */
|
||||
wireApi?: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
export interface WriteSummary {
|
||||
paths: string[];
|
||||
nextStep: string;
|
||||
/** Non-fatal issues the command should surface to the user. */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/** An agent configuration writer: a human label plus a `write` that applies it. */
|
||||
@@ -20,6 +27,83 @@ export interface AgentDef {
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip JSONC syntax (line / block comments and trailing commas) so the result
|
||||
* parses with `JSON.parse`. String contents are preserved verbatim.
|
||||
*/
|
||||
export function stripJsonc(text: string): string {
|
||||
// Pass 1 — drop comments (string contents preserved verbatim).
|
||||
let uncommented = "";
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (inString) {
|
||||
uncommented += char;
|
||||
if (char === "\\") {
|
||||
uncommented += next ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
while (index < text.length && text[index] !== "\n") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// Pass 2 — drop trailing commas (a comma whose next non-whitespace char
|
||||
// closes an object/array). Runs after comment removal so a trailing comment
|
||||
// cannot hide the closing bracket.
|
||||
let output = "";
|
||||
index = 0;
|
||||
inString = false;
|
||||
while (index < uncommented.length) {
|
||||
const char = uncommented[index];
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (char === "\\") {
|
||||
output += uncommented[index + 1] ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = true;
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1;
|
||||
if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
@@ -30,6 +114,16 @@ export function readJson(path: string): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */
|
||||
export function readJsonc(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -57,3 +151,65 @@ export function backup(path: string): void {
|
||||
export function isAnthropicEndpoint(baseUrl: string): boolean {
|
||||
return baseUrl.includes("/apps/anthropic");
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code speaks Anthropic Messages only. Users often paste the OpenAI
|
||||
* compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise
|
||||
* fail with a clear USAGE error before writing a broken config.
|
||||
*/
|
||||
export function resolveClaudeCodeBaseUrl(baseUrl: string): {
|
||||
url: string;
|
||||
rewrittenFrom?: string;
|
||||
} {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
|
||||
if (isAnthropicEndpoint(trimmed)) {
|
||||
return { url: trimmed };
|
||||
}
|
||||
|
||||
if (trimmed.includes("/compatible-mode")) {
|
||||
const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic");
|
||||
return { url: rewritten, rewrittenFrom: baseUrl.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const host = parsed.hostname;
|
||||
const isDashScopeHost =
|
||||
host.includes("dashscope") ||
|
||||
host.includes("maas.aliyuncs.com") ||
|
||||
host.includes("token-plan");
|
||||
if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) {
|
||||
return {
|
||||
url: `${parsed.origin}/apps/anthropic`,
|
||||
rewrittenFrom: baseUrl.trim(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the USAGE error below.
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Model Studio region id into a Token Plan base URL, used in place of
|
||||
* --base-url. Produces the OpenAI-compatible endpoint; the claude-code writer
|
||||
* rewrites it to /apps/anthropic on its own, and the other writers consume the
|
||||
* compatible-mode URL directly.
|
||||
*/
|
||||
export function resolveRegionBaseUrl(region: string): string {
|
||||
const normalized = region.trim();
|
||||
if (!/^[a-z0-9-]+$/.test(normalized)) {
|
||||
throw new BailianError(
|
||||
`Invalid --region "${region}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a Model Studio region id, e.g. cn-beijing or ap-southeast-1.",
|
||||
);
|
||||
}
|
||||
return `https://token-plan.${normalized}.maas.aliyuncs.com/compatible-mode/v1`;
|
||||
}
|
||||
|
||||
@@ -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() : "";
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,12 @@ export default defineCommand({
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
|
||||
required: true,
|
||||
},
|
||||
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
|
||||
value: {
|
||||
type: "string",
|
||||
valueHint: "<value>",
|
||||
description: "Value to set",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
"--key output --value json",
|
||||
@@ -44,7 +49,9 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
|
||||
await ctx.configStore.write({
|
||||
[resolvedKey]: coerced,
|
||||
} as Partial<ConfigFile>);
|
||||
|
||||
if (!settings.quiet) {
|
||||
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
resolveOutputDir,
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
BailianError,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageEditApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
@@ -32,17 +31,6 @@ import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const EDIT_FLAGS = {
|
||||
image: {
|
||||
type: "array",
|
||||
@@ -59,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",
|
||||
@@ -77,6 +65,12 @@ const EDIT_FLAGS = {
|
||||
valueHint: "<text>",
|
||||
description: "Negative prompt to exclude unwanted content",
|
||||
},
|
||||
function: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description:
|
||||
"wanx*-imageedit function (default: description_edit). Examples: stylization_all, description_edit",
|
||||
},
|
||||
promptExtend: {
|
||||
type: "boolean",
|
||||
valueHint: "<bool>",
|
||||
@@ -114,6 +108,8 @@ export default defineCommand({
|
||||
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
|
||||
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
|
||||
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
|
||||
'--image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview',
|
||||
'--image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all',
|
||||
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -127,8 +123,8 @@ export default defineCommand({
|
||||
}
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const useSync = isSyncModel(model);
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
|
||||
const route = resolveImageEditApi(model);
|
||||
|
||||
// Auto-upload local files (resolve all images in parallel)
|
||||
const resolvedImages = await Promise.all(
|
||||
@@ -138,71 +134,128 @@ export default defineCommand({
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
// Build content: all images first, then text prompt
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(u: string) => ({ image: u }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
size: resolveImageSize(flags.size, useSync),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size: resolveImageSize(flags.size, route.sizeProfile),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
let body: DashScopeImageRequest;
|
||||
if (route.inputStyle === "function-base-image") {
|
||||
const baseImageUrl = resolvedImages[0];
|
||||
if (!baseImageUrl) {
|
||||
throw new BailianError(
|
||||
"wanx*-imageedit requires at least one --image as base_image_url.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
function: flags.function || "description_edit",
|
||||
prompt,
|
||||
base_image_url: baseImageUrl,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else if (route.inputStyle === "prompt-images") {
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
images: resolvedImages,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else {
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(imageUrl: string) => ({ image: imageUrl }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Remove undefined parameters
|
||||
stripUndefined(body.parameters as Record<string, unknown>);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format);
|
||||
let previewBody: DashScopeImageRequest = body;
|
||||
if ("messages" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
} else if ("images" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)),
|
||||
},
|
||||
};
|
||||
} else if ("base_image_url" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
base_image_url: redactDataUri(body.input.base_image_url),
|
||||
mask_image_url: body.input.mask_image_url
|
||||
? redactDataUri(body.input.mask_image_url)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
emitResult(
|
||||
{ request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -210,6 +263,7 @@ export default defineCommand({
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -217,15 +271,15 @@ async function handleSyncMode(
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({
|
||||
path: imageSyncPath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -239,6 +293,7 @@ async function handleSyncMode(
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -249,14 +304,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
if (flags.async) {
|
||||
emitResult({ task_ids: taskIds }, format);
|
||||
@@ -269,12 +324,12 @@ async function handleAsyncMode(
|
||||
url: client.url(taskPath(taskId)),
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -285,13 +340,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -321,8 +376,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
generateFilename,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageGenerateApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
} from "bailian-cli-core";
|
||||
@@ -31,24 +30,12 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint.
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const GENERATE_FLAGS = {
|
||||
prompt: { type: "string", valueHint: "<text>", description: "Image description", required: true },
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Model ID (default: qwen-image-2.0)",
|
||||
description: "Model ID (default: qwen-image-3.0)",
|
||||
},
|
||||
size: {
|
||||
type: "string",
|
||||
@@ -109,6 +96,8 @@ export default defineCommand({
|
||||
'--prompt "Logo" --watermark false',
|
||||
'--prompt "An alien in the space" --watermark false',
|
||||
'--prompt "sunset" --model wan2.6-t2i --async --quiet',
|
||||
'--prompt "plush doll" --model z-image-turbo --size 1024*1024',
|
||||
'--prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024',
|
||||
'--prompt "Pro quality" --model qwen-image-2.0-pro',
|
||||
'--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
|
||||
],
|
||||
@@ -116,74 +105,91 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const useSync = isSyncModel(model);
|
||||
const defaultSize = useSync ? "1:1" : "1:1";
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-3.0";
|
||||
const route = resolveImageGenerateApi(model);
|
||||
const defaultSize = "1:1";
|
||||
const sizeInput = flags.size || defaultSize;
|
||||
const size = resolveImageSize(sizeInput, useSync);
|
||||
const size = resolveImageSize(sizeInput, route.sizeProfile);
|
||||
const n = flags.n ?? 1;
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
const body: DashScopeImageRequest =
|
||||
route.inputStyle === "prompt"
|
||||
? {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
emitResult(
|
||||
{ request: body, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`);
|
||||
}
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Sync mode: qwen-image-2.0 series ----
|
||||
// ---- Sync mode: qwen-image / wan2.7-image / z-image ----
|
||||
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
concurrent: number,
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path: imageSyncPath(), method: "POST", body }),
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path, method: "POST", body }),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -194,12 +200,12 @@ async function handleSyncMode(
|
||||
await saveImages(imageUrls, flags, settings, format);
|
||||
}
|
||||
|
||||
// ---- Async mode: wan2.x / qwen-image-plus ----
|
||||
// ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ----
|
||||
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
@@ -210,14 +216,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
// --async: return all task IDs immediately
|
||||
if (flags.async) {
|
||||
@@ -234,12 +240,12 @@ async function handleAsyncMode(
|
||||
url: pollUrl,
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -250,13 +256,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -298,8 +304,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ResourceAddress } from "@openagentpack/sdk";
|
||||
|
||||
/** Full state address: provider.type.name */
|
||||
export function formatResourceAddress(address: ResourceAddress): string {
|
||||
return `${address.provider}.${address.type}.${address.name}`;
|
||||
}
|
||||
|
||||
/** CLI display short label: type.name (provider) */
|
||||
export function formatResourceLabel(address: ResourceAddress): string {
|
||||
return `${address.type}.${address.name} (${address.provider})`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
createProjectRuntime,
|
||||
type LoadedProjectConfig,
|
||||
type ProjectRuntimeContext,
|
||||
resolveProjectConfig,
|
||||
UserError,
|
||||
} from "@openagentpack/sdk";
|
||||
import {
|
||||
assertProviderCredentials,
|
||||
type CredentialHost,
|
||||
injectProviderCredentials,
|
||||
normalizeInterpolatedProviderBlocks,
|
||||
prepareProviderEnv,
|
||||
scrubCredentialEnv,
|
||||
} from "./credentials.ts";
|
||||
import { loadFileState } from "./file-state-manager.ts";
|
||||
import { type HostContext, installSdkTransport } from "./transport.ts";
|
||||
|
||||
export { CREDENTIALS_NOTE, OFFLINE_NOTE } from "./credentials.ts";
|
||||
|
||||
/**
|
||||
* Whether this run requires provider keys:
|
||||
* - "all" (default) — online command: every provider declared in agents.yaml
|
||||
* must have a non-empty key after injection
|
||||
* - "none" — offline command (local config/state only), skip the check
|
||||
*/
|
||||
export type CredentialScope = "all" | "none";
|
||||
|
||||
interface AgentConfigOptions {
|
||||
resolveEnv?: boolean;
|
||||
projectName?: string;
|
||||
statePath?: string;
|
||||
credentials?: CredentialScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve agents.yaml with credentials injected the bl way and scrubbed from the
|
||||
* environment — the shared credential spine for every SDK-engine command:
|
||||
* 1. prepare env (SDK bootstrap for non-bailian + placeholders so interpolation
|
||||
* never throws on a value we're about to supply/reject)
|
||||
* 2. resolve + interpolate the config
|
||||
* 3. override the bailian block with the CLI auth chain's credential (in-memory)
|
||||
* 4. scrub all credential vars from process.env (real values now live only in
|
||||
* the config object → provider adapters, never the environment)
|
||||
* 5. fail with a CLI-authoritative AUTH error if any provider's key is empty
|
||||
* (offline commands pass `credentials: "none"` to skip the check)
|
||||
*/
|
||||
export async function resolveAgentProjectConfig(
|
||||
host: CredentialHost,
|
||||
filePath: string,
|
||||
options: AgentConfigOptions = {},
|
||||
): Promise<LoadedProjectConfig> {
|
||||
prepareProviderEnv();
|
||||
const resolved = await resolveProjectConfig(filePath, options);
|
||||
normalizeInterpolatedProviderBlocks(resolved.config.providers);
|
||||
injectProviderCredentials(resolved.config.providers, host);
|
||||
scrubCredentialEnv();
|
||||
if ((options.credentials ?? "all") !== "none") {
|
||||
assertProviderCredentials(resolved.config.providers);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full ProjectRuntimeContext from a config file path — the standard
|
||||
* entry point for agent commands that need the SDK engine. Mirrors OpenAgentPack
|
||||
* CLI's buildCliRuntime: resolve config → load local state → assemble runtime.
|
||||
* Takes the host context first so every SDK-engine command wires the
|
||||
* instrumented transport (UA / tracking headers / verbose) and the bl-resolved,
|
||||
* in-memory-injected credential ({@link resolveAgentProjectConfig}) by construction.
|
||||
*/
|
||||
export async function buildAgentRuntime(
|
||||
host: HostContext & CredentialHost,
|
||||
filePath: string,
|
||||
options: AgentConfigOptions = {},
|
||||
): Promise<ProjectRuntimeContext & { configPath: string }> {
|
||||
installSdkTransport(host);
|
||||
const { config, configPath, projectName } = await resolveAgentProjectConfig(
|
||||
host,
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
const state = await loadFileState(configPath, options.statePath, projectName);
|
||||
const ctx = createProjectRuntime({
|
||||
projectName,
|
||||
config,
|
||||
state,
|
||||
configPath,
|
||||
providers: config.providers,
|
||||
});
|
||||
return { ...ctx, configPath };
|
||||
}
|
||||
|
||||
/** Ensure a user-supplied --provider value is actually configured in agents.yaml. */
|
||||
export function assertProviderConfigured(
|
||||
ctx: ProjectRuntimeContext,
|
||||
provider: string | undefined,
|
||||
): void {
|
||||
if (!provider || provider === "all") return;
|
||||
if (ctx.providers.has(provider)) return;
|
||||
const available = Array.from(ctx.providers.keys()).join(", ") || "none";
|
||||
throw new UserError(
|
||||
`Provider '${provider}' is not configured. Available providers: ${available}.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Redirect `console.log` / `console.info` to stderr while `fn` runs.
|
||||
*
|
||||
* The OpenAgentPack SDK's provider adapters emit progress/debug logging via
|
||||
* `console.log` (e.g. `[skill-upload]`), which would corrupt bl's stdout data
|
||||
* channel in `--output json` mode. Wrapping SDK calls that may log keeps stdout
|
||||
* a clean data channel. Restores the originals on completion.
|
||||
*/
|
||||
export async function withStdoutProtected<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const originalLog = console.log;
|
||||
const originalInfo = console.info;
|
||||
const toStderr = (...args: unknown[]): void => {
|
||||
process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`);
|
||||
};
|
||||
console.log = toStderr;
|
||||
console.info = toStderr;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.info = originalInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AGENTS_PROVIDER_FIELDS, bootstrapRuntimeCredentialsSync } from "@openagentpack/sdk";
|
||||
import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* AgentStudio API path the SDK's BailianClient serves resources under. bl's
|
||||
* `base_url` is the bare model-service origin (e.g. https://dashscope.aliyuncs.com);
|
||||
* the SDK appends resource paths onto the bailian provider's `base_url` verbatim,
|
||||
* so the agent path must carry this suffix. See OpenAgentPack BailianClient.
|
||||
*/
|
||||
const AGENTSTUDIO_API_PATH = "/api/v1/agentstudio";
|
||||
|
||||
/**
|
||||
* Every env var the SDK recognizes as provider credential material (primary keys
|
||||
* from the SDK's own field map) plus bl-side interpolation aliases and bailian's
|
||||
* endpoint var (not part of AGENTS_PROVIDER_FIELDS). These are the only vars the
|
||||
* pipeline placeholders (to keep interpolation from throwing) and scrubs (so no
|
||||
* real credential persists in the environment).
|
||||
*/
|
||||
const CREDENTIAL_ENV_KEYS = [
|
||||
...new Set([
|
||||
...Object.values(AGENTS_PROVIDER_FIELDS).flatMap((fields) => fields.map((field) => field.key)),
|
||||
"BAILIAN_API_KEY",
|
||||
"BAILIAN_BASE_URL",
|
||||
"CLAUDE_API_KEY",
|
||||
"QODER_API_KEY",
|
||||
]),
|
||||
];
|
||||
|
||||
/** How to obtain each provider's key, surfaced in the CLI's own AUTH error when it is missing. */
|
||||
const CREDENTIAL_HINTS: Record<string, string> = {
|
||||
bailian: "Run `bl auth login --api-key <key>`, pass --api-key, or set DASHSCOPE_API_KEY.",
|
||||
claude: "Set ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in your shell or .env.",
|
||||
ark: "Set ARK_API_KEY in your shell or .env.",
|
||||
qoder: "Set QODER_PAT (or QODER_API_KEY) in your shell or .env.",
|
||||
};
|
||||
|
||||
/** The slice of CommandContext the credential pipeline needs: authStage-resolved client + settings. */
|
||||
export interface CredentialHost {
|
||||
client: Client;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `--help` note documenting where agent commands get provider
|
||||
* credentials. Bailian goes through bl's own auth chain (commands declare
|
||||
* `auth: "apiKey"`); other providers come from env. Either way the resolved
|
||||
* credential is injected into the SDK in-memory and scrubbed from the
|
||||
* environment. Attach to every command that loads agents.yaml. `bl` prefix is
|
||||
* safe: agent commands ship on `bl` only.
|
||||
*/
|
||||
export const CREDENTIALS_NOTE = [
|
||||
"Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).",
|
||||
"Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.",
|
||||
"Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared `--help` note for commands that never talk to a provider: they load
|
||||
* agents.yaml / local state only, so no login or provider key is required.
|
||||
*/
|
||||
export const OFFLINE_NOTE = [
|
||||
"Runs fully offline against local files: no login or provider credentials required.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the SDK's env-based credential sources (`.env`, `~/.agents/config.json`)
|
||||
* for non-bailian providers, then placeholder every credential var that is still
|
||||
* unset with "" so agents.yaml `${VAR}` interpolation never throws on a value the
|
||||
* pipeline is about to supply (bailian) or authoritatively reject ({@link
|
||||
* assertProviderCredentials}). Runs every call (no I/O cache) so a scrubbed
|
||||
* environment is repopulated if the same process resolves more than one config.
|
||||
*/
|
||||
export function prepareProviderEnv(): void {
|
||||
bootstrapRuntimeCredentialsSync();
|
||||
for (const key of CREDENTIAL_ENV_KEYS) {
|
||||
if (process.env[key] === undefined) process.env[key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the bailian provider block with bl's authStage-resolved credential, so
|
||||
* the bailian API key is authoritatively the CLI auth chain's — never a config
|
||||
* file bare-read or a stale env value. `api_key` is replaced unconditionally
|
||||
* when a credential resolved; `base_url` / `workspace_id` are filled only when
|
||||
* the block references them and the interpolated value is empty (a literal in
|
||||
* agents.yaml is respected).
|
||||
*
|
||||
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
|
||||
* paths onto it verbatim; a value already ending in the suffix is left as-is.
|
||||
* It is filled even without a credential — `client.baseUrl` is readable
|
||||
* credential-less (defaults to the CLI's model-domain base URL) — so offline
|
||||
* commands (which skip the credential assert) still satisfy the SDK's
|
||||
* "workspace_id or base_url" schema. With no credential the `api_key` is left
|
||||
* untouched: online commands reject it via {@link assertProviderCredentials}.
|
||||
*/
|
||||
export function injectProviderCredentials(
|
||||
providers: Record<string, unknown>,
|
||||
host: CredentialHost,
|
||||
): void {
|
||||
const bailian = providers.bailian;
|
||||
if (!bailian || typeof bailian !== "object") return;
|
||||
const block = bailian as Record<string, unknown>;
|
||||
|
||||
const cred = host.client.exportApiCredential();
|
||||
if (cred) block.api_key = cred.token;
|
||||
if ("base_url" in block && !block.base_url) {
|
||||
// Defensive normalization: the auth chain already normalizes base_url to
|
||||
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
|
||||
const origin = host.client.baseUrl.replace(/\/+$/, "");
|
||||
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
|
||||
? origin
|
||||
: `${origin}${AGENTSTUDIO_API_PATH}`;
|
||||
}
|
||||
if ("workspace_id" in block && !block.workspace_id && host.settings.workspaceId) {
|
||||
block.workspace_id = host.settings.workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every credential var from `process.env` after interpolation has run and
|
||||
* bailian has been overridden in-memory. From here on the real credentials live
|
||||
* only in the config object (and, after `createProjectRuntime`, in each provider
|
||||
* adapter instance) — nothing persists in the environment for the process
|
||||
* lifetime or any child process.
|
||||
*/
|
||||
export function scrubCredentialEnv(): void {
|
||||
for (const key of CREDENTIAL_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK interpolates `${VAR}` into the raw YAML text, so an empty env var
|
||||
* leaves `api_key:` with nothing after it — YAML parses that as null. Normalize
|
||||
* every null provider field back to "" so the pipeline stays uniform: for
|
||||
* online commands an empty api_key is caught by {@link
|
||||
* assertProviderCredentials}; for offline commands (which skip the assert) the
|
||||
* blocks still satisfy the SDK's string schemas instead of failing zod with
|
||||
* "received null" before the run even starts.
|
||||
*/
|
||||
export function normalizeInterpolatedProviderBlocks(providers: Record<string, unknown>): void {
|
||||
for (const raw of Object.values(providers)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const block = raw as Record<string, unknown>;
|
||||
for (const [fieldName, value] of Object.entries(block)) {
|
||||
if (value === null) block[fieldName] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After injection, fail with a CLI-authoritative AUTH error if any configured
|
||||
* provider's `api_key` resolved empty (missing env var, or no bl login for
|
||||
* bailian). Replaces the SDK's raw `Environment variable '...' is not set` /
|
||||
* zod config error with a clean message plus a provider-specific hint. Validates
|
||||
* every declared provider, so a project is only runnable once all its providers'
|
||||
* keys are available; offline commands skip the check entirely.
|
||||
*/
|
||||
export function assertProviderCredentials(providers: Record<string, unknown>): void {
|
||||
for (const [name, raw] of Object.entries(providers)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const block = raw as Record<string, unknown>;
|
||||
if (!("api_key" in block)) continue;
|
||||
const apiKey = block.api_key;
|
||||
if (typeof apiKey === "string" && apiKey.trim()) continue;
|
||||
throw new BailianError(
|
||||
`Provider '${name}' is configured but its API key is empty.`,
|
||||
ExitCode.AUTH,
|
||||
CREDENTIAL_HINTS[name] ?? `Provide credentials for provider '${name}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { UserError } from "@openagentpack/sdk";
|
||||
import { type ApiErrorBody, BailianError, ExitCode, mapApiError } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Structural shape of the SDK's `ApiError` (thrown by provider clients on HTTP
|
||||
* 4xx/5xx). Matched on fields instead of `instanceof` because the installed SDK
|
||||
* version does not export the class yet, and structural matching keeps this
|
||||
* check stable across SDK versions either way.
|
||||
*/
|
||||
interface SdkApiErrorLike extends Error {
|
||||
statusCode: number;
|
||||
responseBody: string;
|
||||
}
|
||||
|
||||
function isSdkApiError(error: Error): error is SdkApiErrorLike {
|
||||
const candidate = error as Partial<SdkApiErrorLike>;
|
||||
return typeof candidate.statusCode === "number" && typeof candidate.responseBody === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK embeds the raw response body in its error message; recover the
|
||||
* structured fields (message / code / request_id) when the body is JSON so
|
||||
* `mapApiError` surfaces a clean server message plus api metadata. Non-JSON
|
||||
* bodies pass through verbatim as the message.
|
||||
*/
|
||||
function parseSdkResponseBody(raw: string): ApiErrorBody {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as ApiErrorBody;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return { message: raw.trim() || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK's session polling deadline surfaces as a plain `UserError` (no
|
||||
* dedicated timeout class as of SDK 0.3.x), so it is recognized by its stable
|
||||
* message shape: "Session did not complete within the timeout (N seconds)."
|
||||
* (session-runtime's assertNotTimedOut — the SDK's only timeout UserError).
|
||||
* It is a client-side wait limit, not a usage mistake → per bl's error
|
||||
* boundary it must exit TIMEOUT, not USAGE.
|
||||
*/
|
||||
function isSdkPollingTimeout(error: UserError): boolean {
|
||||
return /did not complete within the timeout/i.test(error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an SDK-backed operation, translating SDK error types into BailianError so
|
||||
* bl's error handler produces the right exit code and hint formatting.
|
||||
* SDK `UserError` → USAGE — except the polling-deadline UserError, which is a
|
||||
* client-side timeout → TIMEOUT with a wait-longer hint; SDK `ApiError`
|
||||
* (server HTTP error) → GENERAL via `mapApiError` (server message passed
|
||||
* through verbatim, with httpStatus/apiCode/requestId metadata for
|
||||
* --output json); fetch transport failures (`TypeError: fetch failed`) are
|
||||
* rethrown untouched so the runtime error handler maps them to NETWORK with an
|
||||
* errno-specific hint, matching the native client path; any other Error →
|
||||
* GENERAL (message passed through, per bl's "don't translate server errors"
|
||||
* boundary).
|
||||
*/
|
||||
export async function withAgentErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
if (error instanceof UserError) {
|
||||
if (isSdkPollingTimeout(error)) {
|
||||
throw new BailianError(
|
||||
error.message,
|
||||
ExitCode.TIMEOUT,
|
||||
// `bl` prefix is safe: agent commands ship on `bl` only.
|
||||
"The session may still be running — check `bl managed-agent session get --session-id <id>` or `session events`.",
|
||||
);
|
||||
}
|
||||
throw new BailianError(error.message, ExitCode.USAGE);
|
||||
}
|
||||
if (error instanceof Error && isSdkApiError(error)) {
|
||||
throw mapApiError(error.statusCode, parseSdkResponseBody(error.responseBody));
|
||||
}
|
||||
// DNS/TCP/TLS failures from the SDK's fetch: keep the original TypeError so
|
||||
// the runtime error handler classifies it as NETWORK (exit 6) + errno hint.
|
||||
if (error instanceof TypeError && error.message === "fetch failed") throw error;
|
||||
if (error instanceof Error) throw new BailianError(error.message, ExitCode.GENERAL);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RuntimeFeedbackEvent } from "@openagentpack/sdk";
|
||||
|
||||
/**
|
||||
* Render SDK runtime feedback to stderr, keeping stdout a clean data channel.
|
||||
* Used as the `onFeedback` sink for plan/apply so progress messages don't mix
|
||||
* with structured output.
|
||||
*/
|
||||
export function renderAgentFeedback(event: RuntimeFeedbackEvent): void {
|
||||
process.stderr.write(`${event.message}\n`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import {
|
||||
type IStateManager,
|
||||
LocalFileStateBackend,
|
||||
StateManager,
|
||||
type StateScope,
|
||||
} from "@openagentpack/sdk";
|
||||
|
||||
function createStateScope(configPath: string, projectName?: string): StateScope {
|
||||
const resolved = resolve(configPath);
|
||||
return { projectId: projectName ?? basename(dirname(resolved)) };
|
||||
}
|
||||
|
||||
/** Load or initialize a file-based StateManager (mirrors OpenAgentPack CLI). */
|
||||
export async function loadFileState(
|
||||
configPath: string,
|
||||
statePath?: string,
|
||||
projectName?: string,
|
||||
): Promise<IStateManager> {
|
||||
const resolved = resolve(configPath);
|
||||
const backend = new LocalFileStateBackend({ configPath: resolved, statePath });
|
||||
const path = backend.getStatePath(createStateScope(resolved, projectName));
|
||||
return StateManager.load(path);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user