Compare commits

...

52 Commits

Author SHA1 Message Date
Gong Shiqi f9012a6330 Merge pull request #119 from modelstudioai/fix/base-url-origin-only
fix(core): normalize model base URL to origin
2026-07-23 19:35:24 +08:00
若麒 92ee845bdd fix(core): normalize model base URL to origin 2026-07-23 19:31:02 +08:00
Gong Shiqi 678f60be75 Merge pull request #115 from modelstudioai/release/1.10.1
chore(release): prepare 1.10.1
2026-07-22 11:35:36 +08:00
若麒 d11b55b956 chore(release): prepare 1.10.1 2026-07-22 11:30:43 +08:00
Gong Shiqi e4e3f069e1 Merge pull request #112 from modelstudioai/chore/optimize-skill
docs: optimize bailian-cli skill routing and consent rules
2026-07-22 10:56:15 +08:00
Gong Shiqi 440cbfe6ae Merge pull request #114 from modelstudioai/feat/token-plan-default-models
feat: update Token Plan defaults and support local image inputs
2026-07-22 10:52:35 +08:00
若麒 1adfe797bd docs: simplify Bailian skill consent rules 2026-07-22 10:50:25 +08:00
Gong Shiqi d04012b0a4 Merge pull request #113 from modelstudioai/feat/node-engines-limit
Feat/node engines limit
2026-07-22 10:33:07 +08:00
若麒 81539005cc Merge branch 'main' into feat/token-plan-default-models 2026-07-22 09:55:39 +08:00
若麒 4c566fd60e feat(token-plan): support local images with base64 data URIs
- convert local images to Base64 for Token Plan image and video commands
- preserve the existing OSS upload flow for standard API Key profiles
- use wan2.7-image as the default image model with the sync endpoint
- hide full Base64 image content in dry-run output
- add Token Plan compatibility tests and update related docs
2026-07-22 09:54:54 +08:00
rendianmeng 853ce3caae docs: update README.zh.md 2026-07-21 14:10:49 +08:00
rendianmeng 3b779a708d feat: node engines limit change 2026-07-21 13:55:38 +08:00
clh02467605 b4a2a1c42d docs: optimize bailian-cli skill 2026-07-20 18:26:08 +08:00
若麒 4ca3e2de80 feat(config): update token-plan default models
- switch the default text model to qwen3.8-max-preview
- add dedicated T2V, I2V, and R2V model defaults
- persist and consume per-mode video model settings
- enable thinking when validating the qwen3.8 preview model
2026-07-20 16:44:53 +08:00
Gong Shiqi 1f91fa42fa Merge pull request #109 from modelstudioai/feat/cli-access-token
chore(release): prepare 1.10.0
2026-07-19 16:55:33 +08:00
若麒 39f12e1a78 chore(release): prepare 1.10.0 2026-07-19 16:47:29 +08:00
若麒 a853319dd0 feat(onboarding): add Token Plan setup guidance
- add Token Plan subscription and login entry to CLI, README, INSTALL, and skill
- document built-in Base URL and automatic key validation
- remove the completed Token Plan integration design document
2026-07-19 16:05:35 +08:00
若麒 bc162f4678 Merge branch 'main' into feat/cli-access-token 2026-07-19 15:32:20 +08:00
Gong Shiqi 12ecac4d96 Merge pull request #107 from modelstudioai/feat/config-agent
feat: add `bl config agent` command for one-click coding agent configuration
2026-07-19 11:48:36 +08:00
Gong Shiqi 52f6e267e6 Merge pull request #108 from modelstudioai/fix/bailian-cli-skill
fix(skill): refine Bailian provider routing and consent
2026-07-19 11:09:43 +08:00
若麒 c9e5913034 Merge branch 'main' into fix/bailian-cli-skill 2026-07-19 11:03:25 +08:00
lisheng.lisheng ba062c1a71 feat: add bl config agent command for one-click coding agent configuration
Add `bl config agent` to configure a coding agent (Claude Code, Qwen Code,
OpenCode, OpenClaw, Hermes, Codex) to use a DashScope/ModelStudio endpoint
with a single command. Writers non-destructively merge into each agent's
local config with a timestamped backup and atomic writes.

- Field structures aligned to farion1231/cc-switch; qwen-code aligned to
  QwenLM/qwen-code source (modelProviders/security.auth keyed by protocol).
- provider id unified as `bailian-cli` (qwen-code brands via model entry
  name + BAILIAN_CLI_API_KEY, since it keys by protocol).
- Codex config.toml merged via smol-toml to preserve unrelated settings.
- Add writer unit tests + config e2e cases; regenerate skill reference.
2026-07-18 19:29:53 +08:00
若麒 a03ba673be fix(auth): clear model base URL on full logout 2026-07-17 15:57:23 +08:00
Gong Shiqi 8196f67300 Merge pull request #95 from modelstudioai/feat/cli-access-token
feat: CLI access token 自动化 + bootstrap 一键开通 + 多命名配置 + config ui
2026-07-17 15:10:48 +08:00
若麒 4b504a1a52 docs(changelog): refine 1.9.0 release notes 2026-07-17 15:04:50 +08:00
lisheng.lisheng 00b4d095ed refactor(cli): rename bootstrap command to workspace init
- 将原有的 `bl bootstrap` 命令重命名为 `bl workspace init`
- 更新相关文档,包括中文和英文变更日志
- 修改命令导出和引用,调整文件与变量命名对应新命令
- 保持初始化 Bailian 工作空间及开通后付费服务功能不变
- 删除旧的 `bl bootstrap` 文档,新增 `bl workspace init` 命令帮助文档
- 更新 CLI 命令索引,替换旧命令为新命令
- 优化命令实现细节,增强代码规范和异常处理一致性
2026-07-17 10:59:46 +08:00
若麒 92719a1a21 Merge branch 'main' into feat/cli-access-token 2026-07-17 10:36:26 +08:00
若麒 310e6ead33 chore(release): prepare 1.9.0 2026-07-17 10:15:47 +08:00
若麒 ece0c8dd1c feat(config): activate explicit profile after successful login 2026-07-17 09:59:14 +08:00
Gong Shiqi 39f92567d3 Merge pull request #106 from modelstudioai/fix/windows-stdin
fix(text): support piped messages on Windows
2026-07-16 17:07:56 +08:00
若麒 8b91b9f35f feat: release 1.8.3 2026-07-16 16:43:22 +08:00
若麒 1fbafa8b1d fix(text): support piped messages on Windows
- use the standard input file descriptor instead of /dev/stdin
- reuse the shared file-or-stdin reader in text chat
2026-07-16 16:42:32 +08:00
若麒 052960e269 fix(config): mask all secret fields in config show 2026-07-16 15:27:18 +08:00
若麒 a8f45e93af fix(config): preserve unmanaged fields when saving profiles 2026-07-16 14:48:29 +08:00
若麒 84805f287c fix(core): normalize model base URLs across all sources
- preserve custom gateway path prefixes
- strip query, fragment, trailing slash, and known SDK suffixes
- normalize flag, environment, config, and fallback sources
- normalize auth and config writes before persistence
- add resolver, login, config, and UI coverage
2026-07-16 14:43:26 +08:00
若麒 196b2a1f51 fix(e2e): avoid live OpenAPI login with placeholder credentials 2026-07-16 11:53:09 +08:00
若麒 de9f1a3889 feat(config): add active profile selection
- persist the active profile in config.json
- resolve config with --config > active_config > default
- add config list and config use commands
- make auth and config writes target the selected profile
- reset activation to default when deleting the active profile
- update config UI with profile activation controls
- keep token refresh and pipeline execution profile-aware
- add loader, UI, auth, and CLI interaction coverage
2026-07-16 11:05:59 +08:00
若麒 75a45e1a2a fix(auth): clear STS credentials on logout 2026-07-16 10:02:00 +08:00
若麒 64ff057fe5 feat(auth): support token-plan model profile login
- add the built-in Token Plan profile preset
- validate and persist model API keys atomically
- materialize the default Base URL and models on login
- preserve flag > env > config precedence
2026-07-15 16:26:43 +08:00
若麒 6d588a57b4 Merge branch 'main' into feat/cli-access-token 2026-07-14 19:23:47 +08:00
lisheng.lisheng 84383f1c83 feat(bootstrap): 支持使用AK/SK生成CLI访问令牌并重构用户创建流程
- 新增命令行参数,支持通过--access-key-id和--access-key-secret传入阿里云凭证
- 集成generateCLIAccessToken接口实现AK/SK转CLI访问令牌
- 调用BailianControl OpenAPI完成用户创建,实现与控制台用户同步
- 新增获取工作空间列表接口,解析agentId以便权限管理
- 调用ResetPolicies4Agent接口完成用户权限授权
- 优化命令步骤日志输出,更详细展示执行流程
- 将轮询次数由120次减少至20次,缩短等待激活时长
- 移除旧有测试代码,适配新实现
- core包新增bailian-control客户端支持对应OpenAPI调用
- client模块添加openApiJson通用方法支持ROA风格接口调用
- console模块导出类型扩展,涵盖新的网关目标类型
- 修改acs请求签名类型支持number类型参数,增强签名兼容性
2026-07-14 08:47:16 +08:00
若麒 fc8351f136 fix(skill): refine provider routing and consent
- scope bl preference to matched Bailian and multimodal tasks
- ask once before provider-neutral remote or billable calls
- avoid routing ordinary text, generic search, and ambiguous usage requests to bl
2026-07-13 19:38:04 +08:00
lisheng.lisheng e0f3d450ae feat(config): add "config ui" local web UI to manage config profiles
启动绑定 127.0.0.1 的本地 HTTP server + 内嵌单页 WebUI,可视化查看/
新建/切换/删除全部命名 profile 并编辑键值与凭证。

- core: readConfigProfiles / deleteConfigProfile 全量配置读写 API
- commands/config/shared.ts: 抽出 VALID_KEYS/别名/校验,set.ts 复用
- commands/shared/local-server.ts: 抽出 listen/openInBrowser,login-console 复用
- config ui: token + Host 校验,--config 决定初始聚焦,密钥明文可编辑
2026-07-13 14:20:32 +08:00
lisheng.lisheng ac4dbb9e88 Merge branch 'main' of github.com:modelstudioai/cli into feat/cli-access-token 2026-07-13 13:13:29 +08:00
lisheng.lisheng 155c9dc883 feat(config): 支持命名配置功能并隔离默认配置数据
- 新增 `--config <name>` 参数支持读取与写入命名的配置块
- 命名配置与默认配置完全隔离,互不影响
- 规范命名配置名称格式,禁止路径穿越及顶层字段冲突
- 配置文件读取写入逻辑改为维护原始完整对象,支持多配置块共存
- AuthStore 和 ConfigStore 均支持命名配置,登录登出只影响指定配置块
- CLI 命令增加对 `--config` 标志的支持,包括 config set/show/auth status 等
- 鉴权状态输出带上配置名和配置文件路径信息
- 提示和报错信息包含配置相关上下文,增强用户体验
- 完善相关单元测试覆盖命名配置行为
2026-07-13 13:04:50 +08:00
lisheng.lisheng e1532bf35c feat(bootstrap): 新增创建控制台用户步骤,完善服务激活流程
- 在 bootstrap 命令流程中添加第 3 步,调用 createUser 接口创建控制台账号用户
- 修改 callApi 函数支持传入请求体参数
- 增强错误日志输出,显示更完整的错误响应内容
- 在服务启动时校验登录信息包含 aliyun.uid,否则抛出错误
- 优化商品检查与激活逻辑,更清晰的状态输出及错误处理
- 在 CLI-core 控制台网关请求中添加日志,打印结构化请求负载,支持 verbose 模式
- BailianError 新增 rawResponse 字段,保存原始错误响应数据
- 增加 bootstrap 命令单元测试,覆盖用户创建及不同初始化场景
- 调整测试框架用例,增加调用控制台网关及错误处理的测试覆盖
2026-07-10 17:57:06 +08:00
lisheng.lisheng 3fb0c7211c feat(auth): add support for optional security token in CLI access token generation 2026-07-10 13:04:05 +08:00
lisheng.lisheng 049eecd991 feat(bootstrap): add command to initialize Bailian workspace and activate postpaid services 2026-07-10 10:06:32 +08:00
lisheng.lisheng 2907ad2625 feat(auth): add command to generate CLI access token 2026-07-10 00:09:10 +08:00
lisheng.lisheng 0f23527bfc feat(core): move generateCLIAccessToken to core and auto-refresh on NotLogined
Move the GenerateCLIAccessToken API call logic into core/auth/refresh-token.ts
so it can be reused across packages. Add refreshAccessToken() which reads
AK/SK from config, calls the API, and persists the new access_token.

Client.console() now catches NotLogined errors and automatically retries
with a refreshed token when AK/SK are available in config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 00:00:57 +08:00
lisheng.lisheng 8e3f8586b0 feat(auth): update access token retrieval in login command 2026-07-09 23:43:31 +08:00
lisheng.lisheng efb5243d0d feat(auth): call GenerateCLIAccessToken on open-api login
When logging in with --open-api, call the GenerateCLIAccessToken API
using the provided AK/SK to obtain an access token and persist it
alongside the credentials in config.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 23:40:47 +08:00
114 changed files with 5858 additions and 689 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ body:
attributes:
label: Node version
description: "Output of node --version"
placeholder: "v22.12.0"
placeholder: "v18.17.0"
validations:
required: true
+16 -15
View File
@@ -56,21 +56,22 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
按当前任务从下表挑一条进入对应文档:
| 场景 | 何时进入 | 详见 |
| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
| 场景 | 何时进入 | 详见 |
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
| 错误文案变更 | 改 `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 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
+45
View File
@@ -6,6 +6,51 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.10.1] - 2026-07-22
### Changed
- Token Plan defaults now use the current text, image, and dedicated text-to-video, image-to-video, and reference-to-video models.
- The Bailian CLI Skill now distinguishes Bailian-specific tasks from ordinary host-agent work more accurately and avoids repeated consent prompts within an approved workflow.
- Published CLI packages now support Node.js 18.17 and later, lowering the previous minimum requirement from Node.js 22.12.
### Fixed
- Token Plan now handles local images correctly for image editing, image-to-video, reference-to-video, and vision understanding without requiring a separately hosted URL.
## [1.10.0] - 2026-07-19
### Added
- **`bl config agent`** — configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope in one command.
### Changed
- The Bailian CLI Skill now routes only matching Bailian and multimodal tasks to `bl`, and asks for consent before provider-neutral remote or billable calls.
### Fixed
- Full `bl auth logout` now clears the model Base URL so later logins cannot inherit a stale custom or Token Plan endpoint.
## [1.9.0] - 2026-07-17
### Added
- **Token Plan support** — log in and call supported models directly without manually configuring the endpoint.
- **Named Config Profiles** — create, switch, and manage isolated configurations; logging in to a named Profile activates it automatically.
- **Console Access Token automation** — generate and automatically refresh Console Access Tokens.
- **`bl workspace init`** — initialize a Bailian workspace and activate the required services in one workflow.
### Fixed
- Improved configuration safety and consistency, including secret masking and preservation of custom configuration fields.
## [1.8.3] - 2026-07-16
### Fixed
- Fixed `bl text chat --messages-file -` failing on Windows by treating standard input as a `/dev/stdin` file path; piped JSON messages are now read from standard input correctly. (#103)
## [1.8.2] - 2026-07-15
### Changed
+45
View File
@@ -6,6 +6,51 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.10.1] - 2026-07-22
### 变更
- Token Plan 默认模型已更新为当前文本、图片,以及文生视频、图生视频和参考生视频的专用模型。
- 百炼 CLI Skill 现在能更准确地区分百炼专属任务与普通宿主 Agent 任务,并避免在已授权的工作流中重复征求同意。
- 已发布的 CLI 包现在支持 Node.js 18.17 及以上版本,最低版本要求由 Node.js 22.12 下调至 18.17。
### 修复
- Token Plan 现在能在图片编辑、图生视频、参考生视频和视觉理解中正确处理本地图片,无需另行托管为 URL。
## [1.10.0] - 2026-07-19
### 新增
- **`bl config agent`** —— 一键配置 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 和 Codex 接入百炼模型服务。
### 变更
- 百炼 CLI Skill 现在只将匹配的百炼任务与多模态任务路由到 `bl`,并会在调用与平台无关的远程或计费能力前征求同意。
### 修复
- 完整执行 `bl auth logout` 时会同时清除模型 Base URL避免后续登录继承失效的自定义或 Token Plan 接入地址。
## [1.9.0] - 2026-07-17
### 新增
- **支持 Token Plan** —— 登录后即可直接调用支持的模型,无需手动配置接入地址。
- **命名 Config Profile** —— 支持创建、切换和管理相互隔离的配置,登录后会自动激活当前 Profile。
- **Console Access Token 自动化** —— 支持生成并自动刷新 Console Access Token。
- **`bl workspace init`** —— 一站式完成百炼工作空间初始化和所需服务开通。
### 修复
- 提升配置安全性与一致性,包括密钥脱敏和自定义配置字段保留。
## [1.8.3] - 2026-07-16
### 修复
- 修复 Windows 上 `bl text chat --messages-file -` 将标准输入当作 `/dev/stdin` 文件路径读取的问题;通过管道传入的 JSON 消息现在可以从标准输入正常读取。(#103
## [1.8.2] - 2026-07-15
### 变更
+2 -1
View File
@@ -6,7 +6,8 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
## Prerequisites
- Node.js ≥ 22.12
- Node.js ≥ 22.12 (required for developing this repo)
- End users installing the published CLI only need Node.js >= 18.17
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
- A DashScope API key for running e2e tests
+3 -2
View File
@@ -6,8 +6,9 @@
## 环境要求
- Node.js ≥ 22.12
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
- 参与本仓库开发:Node.js ≥ 22.12
- 仅安装/使用已发布的 CLINode.js >= 18.17
- pnpm 10.33.2`npm i -g pnpm@10.33.2`
- 跑 e2e 需要一个百炼 API Key
## 仓库结构
+16 -8
View File
@@ -7,7 +7,7 @@
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
1. **Node.js**
- 要求 **22.12.0**,执行 `node -v` 确认。
- 要求 **18.17.0**,执行 `node -v` 确认。
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
2. **npm****唯一**允许的包管理器)
@@ -69,7 +69,7 @@ npx skills add modelstudioai/cli --all -g
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
### 备选:由 Agent 引导用户输入 API Key 后登录
### 备选:由 Agent 引导用户输入普通 API Key 后登录
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
@@ -80,6 +80,15 @@ npx skills add modelstudioai/cli --all -g
3. 用户提供了 Key 之后,在**用户本机终端**执行Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
### 备选二:使用 Token Plan API Key
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
1. 请用户从订阅详情页获取或复制 Token Plan API Key勿要求用户发到公开渠道。
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`
3. `token-plan` Profile 已内置默认 Base URL登录命令会先测试 Key通过后才保存并激活该 Profile无需另行配置或重复测试。
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
### 其他方式
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
@@ -93,16 +102,15 @@ npx skills add modelstudioai/cli --all -g
---
## 4. 最小功能验证
## 4. 配置验证
在鉴权配置完成后执行
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态
```bash
bl auth status --output json
bl text chat --message "ping" --non-interactive --output json
```
失败根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
无需再执行重复的模型调用测试。若登录失败根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
---
@@ -111,7 +119,7 @@ bl text chat --message "ping" --non-interactive --output json
| 现象 | 可能原因 | 建议动作 |
| ----------------------- | -------------------- | --------------------------------------------------------------- |
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 22.12 |
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 引导用户更新 Key 并 `bl auth login --api-key` |
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
+19 -2
View File
@@ -5,7 +5,7 @@
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
@@ -80,7 +81,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 22.12.
> Requires Node.js >= 18.17.
## Quick Start
@@ -91,6 +92,12 @@ bl auth login --console
# Or authenticate with an API key
bl auth login --api-key sk-xxxxx
# Or use Token Plan (Base URL built in; the key is tested during login)
bl auth login --config token-plan --api-key sk-sp-xxxxx
# Configure a coding agent to use DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# Chat with Qwen
bl text chat --message "What is DashScope?"
@@ -159,6 +166,15 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "Hello"
```
### Token Plan API Key
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### Console Login (OAuth)
Required for console capability commands (`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.
@@ -209,6 +225,7 @@ Config file location: `~/.bailian/config.json`
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+19 -2
View File
@@ -5,7 +5,7 @@
**阿里云百炼 (DashScope) AI 平台命令行工具**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -30,6 +30,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
@@ -78,7 +79,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 22.12
> 需要预先安装 Node.js >= 18.17
## 快速开始
@@ -89,6 +90,12 @@ bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 或使用 Token Plan已内置 Base URL登录时自动测试 Key
bl auth login --config token-plan --api-key sk-sp-xxxxx
# 配置 Coding Agent 使用 DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
@@ -157,6 +164,15 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "你好"
```
### Token Plan API Key
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
CLI 已内置 Token Plan 的默认 Base URL登录命令会先测试 Key通过后才保存并激活 `token-plan` 配置。
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### 控制台登录OAuth
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
@@ -207,6 +223,7 @@ bl update
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
+6 -3
View File
@@ -37,13 +37,16 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
- `bl auth logout --console` 只清 `access_token`
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret`
- `bl auth logout``api_key` + `access_token` + `access_key_*`
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
- `bl auth logout``api_key` + `base_url` + `access_token` + `access_key_*`
解析分工:
- `resolveApiKey()``auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin仅保留协议、host 和显式端口,去除 path、query、fragment
- `--config` 只选择 config 文件 block不提升该 block 的字段优先级;内置套餐 Profile当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
- `resolveConsole()``auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
- `resolveOpenApi()``auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
- `describeAuthState()``auth status` / banner / telemetry 使用的只读快照
+3 -1
View File
@@ -67,6 +67,7 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
| OpenAPI AK/SK | `isOpenApiE2EReady()``.env` 中必须同时提供完整 AK/SK |
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()``knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID |
@@ -84,7 +85,8 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
## 安全与例外
- **禁止真实破坏性操作**`auth logout` 只用 `--dry-run``config set` 只用 `--dry-run`
- **禁止破坏真实用户配置**`auth logout` 默认只用 `--dry-run`需要验证实际落盘时,必须通过
`BAILIAN_CONFIG_DIR` 指向隔离 fixture`config set` 只用 `--dry-run`
- **不加 dry-run**`dryRun``resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit``speech recognize``--url`
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag缺参用例勿带该 flag
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
+75
View File
@@ -0,0 +1,75 @@
# Config Profile 与激活状态变更清单
适用于新增 Profile 预设、修改命名 Profile 选择规则、调整 `active_config`,或新增/修改 `bl config list/use/show/ui` 等 Profile 管理能力。
## 1. 保持存储边界
- Profile 业务字段继续由 `ConfigFile` / `CONFIG_FILE_KEYS` 管理。
- `active_config``config.json` 顶层元数据,不得进入命名 Profile block也不得被 `config set` 当作普通字段写入。
- 识别命名 Profile 时必须排除业务字段和顶层元数据。
- 旧配置缺少 `active_config` 时继续等价于激活 `default`
## 2. 保持选择语义
```text
显式 --config <name> > active_config > default
```
- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`
- `--config default` 必须显式选择顶层配置并绕过命名激活项。
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
- 激活状态只选择配置 block不改变字段优先级字段仍为 flag > env > selected config > 默认值。
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。
## 3. 保持读写命令交互一致
- `auth login``config set` 等写命令未传 `--config` 时修改当前激活项。
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时
创建和激活;`config set --config <name>` 可创建但不自动激活。
- `config show``auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。
- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。
- 按凭证域退出时必须清理该域的完整字段集合,例如 OpenAPI 同时清理 AK、SK 和 STS `security_token`
- 所有生产代码读取“当前配置”时优先经过 `buildSources()` 或携带解析后的 `configName`;直接调用无名称的 `readConfigFile()` / `writeConfigFile()` 只适用于明确操作顶层 default 的底层能力。
## 4. 保持状态一致性
- `config use` 只能激活已经存在的命名 Profile`default` 始终有效。
- 配置文件中的 `active_config` 指向不存在的 Profile 时返回 usage error不静默回退。
- 删除当前激活的命名 Profile 时,同一次落盘切回 `default`,不得留下悬空引用。
- 配置写入继续使用临时文件 + rename避免中断后留下半写文件。
## 5. 命令与展示联动
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`
- `config list` 标识所有 Profile 与当前激活项。
- `config show``auth status` 只输出本次最终选择的 `config``config_file`,不重复携带激活状态。
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`
- `config ui` 保存时只替换 UI 管理的字段Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
## 6. 最小测试矩阵
- 旧配置无 `active_config` -> `default`
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
- 显式命名 `--config``--config default` 均覆盖激活项且不修改磁盘状态。
- 激活不存在的 Profile 失败且不写盘。
- 悬空 `active_config` 明确失败。
- 删除激活 Profile 后切回 `default`
- 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。
- 显式 `auth login --config <name>` 成功后激活该 Profile失败或 dry-run 不创建、不切换;
`--config default` 成功后切回 `default`
- Console token 自动刷新不从其他 Profile 借用 AK/SK也不把新 token 写入其他 Profile。
- `config list/show/use/ui``auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
## 7. 完成检查
```sh
pnpm run sync:skill-assets
vp check
vp test
```
命令 E2E 会启动本地子进程Config UI 测试还会监听 `127.0.0.1` 临时端口;受限沙箱内出现 `EPERM` 时,需要在允许本地进程和端口的环境中复跑。
+1 -1
View File
@@ -12,7 +12,7 @@
### A. 版本一致性
- [ ] `package.json``engines.node` 与 README 的 Node.js 徽章一致
- [ ] 发布包(`cli` 等)`engines.node` 与 README 的 Node.js 徽章一致;根/e2e 开发要求(`>=22.12`)与 CONTRIBUTING 一致
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
- [ ] 各源码包 `tsconfig.json`(根 + core + runtime + commands + cli + kscli)的 target / module 设置一致
+12 -12
View File
@@ -93,15 +93,15 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
## 常见漏点(基于历史踩坑)
| 漏点 | 后果 |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| 发版漏更 CHANGELOG或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npmCI 明确报错并要求先升级版本号 |
| 漏点 | 后果 |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
| cli 升版号但 core 没升 | check.mjs 会拦下 |
| 发版漏更 CHANGELOG或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag所有用户被强升撤回成本极高 |
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
| Node 徽章 `cli/package.json.engines` 不一致(当前应为 `>=18.17` | 用户在声明外的 Node 上 `npm i` 被 engine 警告或直接失败 |
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npmCI 明确报错并要求先升级版本号 |
+1
View File
@@ -19,6 +19,7 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
BAILIAN_CONSOLE_ROOT bailian.console.aliyun.com
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
+19 -2
View File
@@ -5,7 +5,7 @@
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 520s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
@@ -80,7 +81,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 22.12.
> Requires Node.js >= 18.17.
## Quick Start
@@ -91,6 +92,12 @@ bl auth login --console
# Or authenticate with an API key
bl auth login --api-key sk-xxxxx
# Or use Token Plan (Base URL built in; the key is tested during login)
bl auth login --config token-plan --api-key sk-sp-xxxxx
# Configure a coding agent to use DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# Chat with Qwen
bl text chat --message "What is DashScope?"
@@ -159,6 +166,15 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "Hello"
```
### Token Plan API Key
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### Console Login (OAuth)
Required for console capability commands (`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.
@@ -209,6 +225,7 @@ Config file location: `~/.bailian/config.json`
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
## Changelog
+19 -2
View File
@@ -5,7 +5,7 @@
**阿里云百炼 (DashScope) AI 平台命令行工具**
[![npm version](https://img.shields.io/npm/v/bailian-cli?color=0969da&label=npm)](https://www.npmjs.com/package/bailian-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -30,6 +30,7 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
- **语音合成与识别** — CosyVoice 实时流式合成5-20s 样本即可克隆FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
- **图像与视频理解** — Qwen-VL长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
> **注意:** 以下功能目前仅对中国站aliyun.com账号开放国际站 / 全球站账号暂不支持。
@@ -78,7 +79,7 @@ npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 22.12
> 需要预先安装 Node.js >= 18.17
## 快速开始
@@ -89,6 +90,12 @@ bl auth login --console
# 或使用 API key 认证
bl auth login --api-key sk-xxxxx
# 或使用 Token Plan已内置 Base URL登录时自动测试 Key
bl auth login --config token-plan --api-key sk-sp-xxxxx
# 配置 Coding Agent 使用 DashScope
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
# 和通义千问对话
bl text chat --message "你好,介绍一下阿里云百炼平台"
@@ -157,6 +164,15 @@ bl auth login --api-key sk-xxxxx
bl text chat --api-key sk-xxxxx --message "你好"
```
### Token Plan API Key
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
CLI 已内置 Token Plan 的默认 Base URL登录命令会先测试 Key通过后才保存并激活 `token-plan` 配置。
```bash
bl auth login --config token-plan --api-key sk-sp-xxxxx
```
### 控制台登录OAuth
控制台能力命令(`model list``app list``usage summary/free/stats``workspace list``quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
@@ -207,6 +223,7 @@ bl update
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
## 更新日志
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.8.2",
"version": "1.10.1",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
@@ -66,6 +66,6 @@
"yaml": "catalog:"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
}
}
+12
View File
@@ -3,6 +3,7 @@ import {
authLogin,
authStatus,
authLogout,
authGenerateAccessToken,
textChat,
textOmni,
imageGenerate,
@@ -15,6 +16,10 @@ import {
visionDescribe,
configShow,
configSet,
configList,
configUse,
configUi,
configAgent,
update,
appCall,
appList,
@@ -79,6 +84,7 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
workspaceInit,
pluginInstall,
pluginLink,
pluginList,
@@ -94,6 +100,7 @@ export const commands: Record<string, AnyCommand> = {
"auth login": authLogin,
"auth status": authStatus,
"auth logout": authLogout,
"auth generate-access-token": authGenerateAccessToken,
"text chat": textChat,
omni: textOmni,
"image generate": imageGenerate,
@@ -106,6 +113,10 @@ export const commands: Record<string, AnyCommand> = {
"vision describe": visionDescribe,
"config show": configShow,
"config set": configSet,
"config list": configList,
"config use": configUse,
"config ui": configUi,
"config agent": configAgent,
update,
"app call": appCall,
"app list": appList,
@@ -170,6 +181,7 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"workspace init": workspaceInit,
"plugin install": pluginInstall,
"plugin link": pluginLink,
"plugin list": pluginList,
@@ -0,0 +1,164 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
function withTempConfigDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
return fn(dir).finally(() => {
rmSync(dir, { recursive: true, force: true });
});
}
function writeConfig(dir: string, data: Record<string, unknown>): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n");
}
describe("e2e: named config", () => {
test("根帮助展示 --config 全局标志", async () => {
const { stderr, exitCode } = await runCli(["--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--config <name>/);
});
test("config set --config 写入命名 block 且不影响默认配置", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, { output: "text", api_key: "sk-default" });
const setResult = await runCli(
[
"config",
"set",
"--config",
"dev",
"--key",
"output",
"--value",
"json",
"--output",
"json",
],
{ BAILIAN_CONFIG_DIR: dir },
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
const setData = parseStdoutJson<{
output?: string;
config?: string;
config_file?: string;
}>(setResult.stdout);
expect(setData.output).toBe("json");
expect(setData.config).toBe("dev");
expect(setData.config_file).toBe(join(dir, "config.json"));
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(raw.output).toBe("text");
expect((raw.dev as Record<string, unknown>).output).toBe("json");
});
});
test("config show --config 只展示命名 block", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
output: "text",
api_key: "sk-default",
dev: { output: "json", access_token: "tok-dev" },
});
const { stdout, stderr, exitCode } = await runCli(
["config", "show", "--config", "dev", "--output", "json"],
{ BAILIAN_CONFIG_DIR: dir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Record<string, unknown>>(stdout);
expect(data.config).toBe("dev");
expect(data.config_file).toBe(join(dir, "config.json"));
expect(data.output).toBe("json");
expect(data.access_token).toBeDefined();
expect(data.api_key).toBeUndefined();
});
});
test("auth status --config 不继承默认凭证", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } });
const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], {
BAILIAN_CONFIG_DIR: dir,
DASHSCOPE_API_KEY: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
expect(devStatus.exitCode, devStatus.stderr).toBe(0);
const devData = parseStdoutJson<Record<string, unknown>>(devStatus.stdout);
expect(devData.authenticated).toBe(false);
expect(devData.config).toBe("dev");
const defaultStatus = await runCli(["auth", "status", "--output", "json"], {
BAILIAN_CONFIG_DIR: dir,
DASHSCOPE_API_KEY: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0);
const defaultData = parseStdoutJson<Record<string, unknown>>(defaultStatus.stdout);
expect(defaultData.authenticated).toBe(true);
expect(defaultData.config).toBe("default");
});
});
test("--config default 等价默认配置", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
active_config: "token-plan",
output: "json",
api_key: "sk-default",
"token-plan": { output: "text", api_key: "sk-token" },
});
const { stdout, stderr, exitCode } = await runCli(
["config", "show", "--config", "default", "--output", "json"],
{ BAILIAN_CONFIG_DIR: dir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<Record<string, unknown>>(stdout);
expect(data.config).toBe("default");
expect(data.active).toBeUndefined();
expect(data.api_key).toBeDefined();
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(raw.active_config).toBe("token-plan");
});
});
test("非法 --config 名称报 usage error", async () => {
const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config name/);
});
test("auth status 文本输出分行展示选中 Config 和配置文件", async () => {
await withTempConfigDir(async (dir) => {
writeConfig(dir, {
active_config: "token-plan",
"token-plan": { api_key: "sk-token" },
});
const result = await runCli(["auth", "status", "--output", "text"], {
BAILIAN_CONFIG_DIR: dir,
DASHSCOPE_API_KEY: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout).toContain("Config: token-plan\n");
expect(result.stdout).toContain(`Config file: ${join(dir, "config.json")}\n`);
expect(result.stdout).not.toContain("Active config:");
});
});
});
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.8.2",
"version": "1.10.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": {
@@ -44,6 +44,7 @@
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
"chalk": "catalog:",
"smol-toml": "catalog:",
"yaml": "catalog:"
},
"devDependencies": {
@@ -54,6 +55,6 @@
"vite-plus": "0.1.22"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
}
}
@@ -0,0 +1,50 @@
import {
defineCommand,
detectOutputFormat,
generateCLIAccessToken,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const FLAGS = {
accessKeyId: {
type: "string",
valueHint: "<id>",
description: "Alibaba Cloud Access Key ID",
required: true,
},
accessKeySecret: {
type: "string",
valueHint: "<secret>",
description: "Alibaba Cloud Access Key Secret",
required: true,
},
securityToken: {
type: "string",
valueHint: "<token>",
description: "Alibaba Cloud STS Security Token to store (optional)",
},
} satisfies FlagsDef;
export default defineCommand({
description: "Generate a CLI access token using OpenAPI AK/SK",
auth: "none",
usageArgs: "--access-key-id <id> --access-key-secret <secret> --security-token <token>",
flags: FLAGS,
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx --security-token <token>"],
async run(ctx) {
const { identity, settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const resp = await generateCLIAccessToken({
identity,
settings,
baseUrl: ctx.client.baseUrl,
accessKeyId: flags.accessKeyId,
accessKeySecret: flags.accessKeySecret,
securityToken: flags.securityToken || undefined,
});
emitResult(resp, format);
},
});
@@ -0,0 +1,100 @@
import {
BailianError,
ExitCode,
chatPath,
requestJson,
normalizeModelBaseUrl,
type AuthPersistPatch,
type AuthStore,
type Identity,
type Settings,
} from "bailian-cli-core";
interface ApiKeyLoginDeps {
identity: Identity;
settings: Settings;
authStore: AuthStore;
}
interface ApiKeyLoginProfile {
baseUrl: string;
persistBaseUrl?: string;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
defaultReferenceToVideoModel?: string;
defaultImageModel?: string;
persistPatch?: AuthPersistPatch;
}
const RETRY_DELAY_BASE_MS = 500;
function canRetry(error: unknown): boolean {
if (error instanceof BailianError) {
if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true;
const status = error.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (error instanceof Error) {
return (
error.name === "AbortError" ||
error.name === "TimeoutError" ||
error.message.includes("timed out") ||
error.message === "fetch failed"
);
}
return false;
}
export async function validateAndPersistApiKey(
deps: ApiKeyLoginDeps,
key: string,
profile: ApiKeyLoginProfile,
): Promise<void> {
process.stderr.write("Testing key... ");
const httpDeps = { identity: deps.identity, settings: deps.settings };
const baseUrl = normalizeModelBaseUrl(profile.baseUrl);
const persistBaseUrl = profile.persistBaseUrl
? normalizeModelBaseUrl(profile.persistBaseUrl)
: undefined;
const validationModel = profile.defaultTextModel || "qwen3.7-max";
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
headers: { Authorization: `Bearer ${key}` },
timeout: Math.min(deps.settings.timeout, 30),
body: {
model: validationModel,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
stream: false,
enable_thinking: validationModel === "qwen3.8-max-preview",
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(httpDeps, requestOpts);
break;
} catch (error) {
if (attempt >= 3 || !canRetry(error)) {
process.stderr.write("Failed\n");
throw error;
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
await deps.authStore.login({
...profile.persistPatch,
api_key: key,
base_url: persistBaseUrl,
default_text_model: profile.defaultTextModel,
default_video_model: profile.defaultVideoModel,
default_image_to_video_model: profile.defaultImageToVideoModel,
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
default_image_model: profile.defaultImageModel,
});
}
@@ -1,18 +1,17 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import http from "node:http";
import {
BailianError,
ExitCode,
chatPath,
getConfigPath,
requestJson,
type AuthPersistPatch,
type AuthStore,
type ConfigFile,
type Identity,
type Settings,
} from "bailian-cli-core";
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
import { validateAndPersistApiKey } from "./login-api-key.ts";
/** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */
export interface LoginDeps {
@@ -23,6 +22,9 @@ export interface LoginDeps {
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
const MAX_AUTH_CALLBACK_BODY = 65536;
// Regex for double newline (\r\n\r\n or \n\n); built via RegExp to avoid
// literal multi-line splitting in source.
const REGEX_DOUBLE_NEWLINE = new RegExp("\r\n\r\n|\n\n");
const CONSOLE_ORIGINS: Record<string, string> = {
domestic: "https://bailian.console.aliyun.com",
@@ -76,7 +78,7 @@ function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): stri
for (let i = 1; i < segments.length; i++) {
const part = segments[i]!;
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
const sep = part.match(/\r\n\r\n|\n\n/);
const sep = part.match(REGEX_DOUBLE_NEWLINE);
if (!sep || sep.index === undefined) continue;
let value = part.slice(sep.index + sep[0].length);
value = value
@@ -359,90 +361,7 @@ async function extractCredentialsFromRequest(
}
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
const onErr = (e: Error) => reject(e);
server.once("error", onErr);
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
server.off("error", onErr);
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Expected TCP socket address"));
return;
}
resolve(addr.port);
});
});
}
function openInBrowser(url: string): Promise<void> {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
const RETRY_DELAY_BASE_MS = 500;
function canRetry(err: unknown): boolean {
if (err instanceof BailianError) {
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) return true;
const status = err.api?.httpStatus;
return status === 401 || (status !== undefined && status >= 500);
}
if (err instanceof Error) {
return (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out") ||
err.message === "fetch failed"
);
}
return false;
}
export async function validateAndPersistApiKey(
deps: LoginDeps,
key: string,
baseUrl: string,
): Promise<void> {
process.stderr.write("Testing key... ");
const httpDeps = { identity: deps.identity, settings: deps.settings };
const requestOpts = {
url: baseUrl + chatPath(),
method: "POST",
headers: { Authorization: `Bearer ${key}` },
timeout: Math.min(deps.settings.timeout, 30),
body: {
model: "qwen3.7-max",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
},
};
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await requestJson<unknown>(httpDeps, requestOpts);
break;
} catch (err) {
if (attempt >= 3 || !canRetry(err)) {
process.stderr.write("Failed\n");
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
cause: err,
});
}
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
process.stderr.write("Valid\n");
await deps.authStore.login({ api_key: key });
return listenLocalServer(server);
}
export async function runConsoleLogin(
@@ -486,20 +405,27 @@ export async function runConsoleLogin(
if (hasConfig || apiKey) {
try {
if (hasConfig) {
await deps.authStore.login({
access_token: accessToken || undefined,
base_url: baseUrl || undefined,
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
console_region: consoleRegion || undefined,
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
workspace_id: workspaceId || undefined,
});
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
}
const callbackPatch: AuthPersistPatch = {
access_token: accessToken || undefined,
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
console_region: consoleRegion || undefined,
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
workspace_id: workspaceId || undefined,
};
if (apiKey) {
const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl();
await validateAndPersistApiKey(deps, apiKey, testBaseUrl);
await validateAndPersistApiKey(deps, apiKey, {
baseUrl: testBaseUrl,
persistBaseUrl: baseUrl || undefined,
persistPatch: callbackPatch,
});
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
} else if (hasConfig) {
await deps.authStore.login({
...callbackPatch,
base_url: baseUrl || undefined,
});
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
}
} catch (err: unknown) {
callbackError = err;
+38 -15
View File
@@ -1,10 +1,12 @@
import { defineCommand, getConfigPath } from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
import {
resolveConsoleOrigin,
runConsoleLogin,
validateAndPersistApiKey,
} from "./login-console.ts";
defineCommand,
generateCLIAccessToken,
getModelProfilePreset,
normalizeModelBaseUrl,
} from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
import { validateAndPersistApiKey } from "./login-api-key.ts";
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api";
@@ -19,11 +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: "DashScope API key to store" },
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
baseUrl: {
type: "string",
valueHint: "<url>",
description: "DashScope API base URL (used with --api-key for validation)",
description: "Model API base URL (used with --api-key for validation)",
},
console: {
type: "switch",
@@ -52,6 +54,7 @@ export default defineCommand({
},
exampleArgs: [
"--api-key sk-xxxxx",
"--config token-plan --api-key sk-sp-xxxxx",
"--console",
"--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx",
],
@@ -90,7 +93,7 @@ export default defineCommand({
const store = ctx.authStore;
const deps = { identity, settings, authStore: store };
const key = flags.apiKey;
const baseUrl = flags.baseUrl || undefined;
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
if (flags.console) {
if (settings.dryRun) {
@@ -110,14 +113,25 @@ export default defineCommand({
if (flags.openApi) {
if (settings.dryRun) {
emitBare("Would save OpenAPI AK/SK credentials.");
emitBare("Would save OpenAPI AK/SK credentials and generate CLI access token.");
return;
}
const resolvedBaseUrl = store.resolveBaseUrl();
process.stderr.write("Generating CLI access token... ");
const resp = await generateCLIAccessToken({
identity,
settings,
baseUrl: resolvedBaseUrl,
accessKeyId: flags.accessKeyId!,
accessKeySecret: flags.accessKeySecret!,
});
const accessToken = resp.cliAccessToken;
await store.login({
access_key_id: flags.accessKeyId,
access_key_secret: flags.accessKeySecret,
access_token: accessToken,
});
process.stderr.write(`OpenAPI credentials saved to ${getConfigPath()}\n`);
process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`);
return;
}
@@ -128,9 +142,18 @@ export default defineCommand({
emitBare("Would validate and save API key.");
return;
}
if (baseUrl) {
await store.login({ base_url: baseUrl });
}
await validateAndPersistApiKey(deps, key, baseUrl || store.resolveBaseUrl());
const profilePreset = getModelProfilePreset(settings.configName);
const storedBaseUrl = store.stored().baseUrl;
const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl);
const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined);
await validateAndPersistApiKey(deps, key, {
baseUrl: resolvedBaseUrl,
persistBaseUrl,
defaultTextModel: profilePreset?.defaultTextModel,
defaultVideoModel: profilePreset?.defaultVideoModel,
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
defaultImageModel: profilePreset?.defaultImageModel,
});
},
});
+17 -13
View File
@@ -1,8 +1,8 @@
import { defineCommand, getConfigPath } from "bailian-cli-core";
import { defineCommand } from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
export default defineCommand({
description: "Clear stored credentials",
description: "Clear stored credentials; full logout also clears the model Base URL",
auth: "none",
usageArgs: "[--console | --open-api] [--dry-run]",
flags: {
@@ -12,7 +12,7 @@ export default defineCommand({
},
openApi: {
type: "switch",
description: "Only clear OpenAPI AK/SK credentials, keep other credentials intact",
description: "Only clear OpenAPI AK/SK/STS credentials, keep other credentials intact",
},
},
exampleArgs: ["", "--console", "--open-api", "--dry-run"],
@@ -25,13 +25,13 @@ export default defineCommand({
if (flags.console) {
if (settings.dryRun) {
if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json");
if (stored.console) emitBare(`Would clear access_token from ${store.path}`);
else emitBare("No console access_token to clear.");
emitBare("No changes made.");
return;
}
if (await store.logout("console")) {
process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`);
process.stderr.write(`Cleared access_token from ${store.path}\n`);
if (stored.apiKey) {
process.stderr.write(
"api_key is still configured and will be used for authentication.\n",
@@ -46,13 +46,17 @@ export default defineCommand({
if (flags.openApi) {
if (settings.dryRun) {
if (stored.openapi)
emitBare("Would clear access_key_id / access_key_secret from ~/.bailian/config.json");
emitBare(
`Would clear access_key_id / access_key_secret / security_token from ${store.path}`,
);
else emitBare("No OpenAPI AK/SK credentials to clear.");
emitBare("No changes made.");
return;
}
if (await store.logout("openapi")) {
process.stderr.write(`Cleared access_key_id / access_key_secret from ${getConfigPath()}\n`);
process.stderr.write(
`Cleared access_key_id / access_key_secret / security_token from ${store.path}\n`,
);
if (stored.apiKey || stored.console) {
process.stderr.write(
"Other credentials are still configured and will be used for authentication.\n",
@@ -64,24 +68,24 @@ export default defineCommand({
return;
}
const hasKey = stored.apiKey || stored.console || stored.openapi;
const hasStoredAuth = stored.apiKey || stored.console || stored.openapi || !!stored.baseUrl;
if (settings.dryRun) {
if (hasKey)
if (hasStoredAuth)
emitBare(
"Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json",
`Would clear api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}`,
);
else emitBare("No credentials to clear.");
else emitBare("No credentials or model Base URL to clear.");
emitBare("No changes made.");
return;
}
if (await store.logout("all")) {
process.stderr.write(
"Cleared api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json\n",
`Cleared api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}\n`,
);
} else {
process.stderr.write("No credentials to clear.\n");
process.stderr.write("No credentials or model Base URL to clear.\n");
}
},
});
+17 -1
View File
@@ -35,11 +35,15 @@ export default defineCommand({
: undefined;
const authenticated = !!(apiKey || consoleCred || openapi);
const configName = settings.configName ?? "default";
const configFile = ctx.authStore.path;
if (!authenticated) {
emitResult(
{
authenticated: false,
config: configName,
config_file: configFile,
message: "Not authenticated.",
hint: [
`API key (model): ${identity.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
@@ -54,10 +58,22 @@ export default defineCommand({
}
if (format !== "text") {
emitResult({ authenticated: true, api_key: apiKey, console: consoleCred, openapi }, format);
emitResult(
{
authenticated: true,
config: configName,
config_file: configFile,
api_key: apiKey,
console: consoleCred,
openapi,
},
format,
);
return;
}
emitBare(`Config: ${configName}`);
emitBare(`Config file: ${configFile}`);
emitBare("Authentication Status:");
if (apiKey) {
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);
@@ -0,0 +1,71 @@
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";
const FLAGS = {
agent: {
type: "string",
valueHint: "<name>",
description: `Target agent: ${VALID_AGENT_NAMES.join(", ")}`,
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 },
model: {
type: "string",
valueHint: "<model>",
description: "Default model name",
required: true,
},
} 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>",
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",
],
async run(ctx) {
const { settings, flags } = ctx;
const agentName = flags.agent;
const { baseUrl, apiKey, model } = flags;
const agentDef = AGENTS[agentName];
const format = detectOutputFormat(settings.output);
// Hermes has no native Windows support.
if (agentName === "hermes" && platform() === "win32") {
process.stderr.write(
"Warning: Hermes Agent does not support native Windows. Please use WSL2.\n",
);
}
if (settings.dryRun) {
emitResult(
{
agent: agentName,
label: agentDef.label,
base_url: baseUrl,
api_key: maskToken(apiKey),
model,
},
format,
);
return;
}
const params: WriteParams = { baseUrl, apiKey, model };
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}`);
}
},
});
@@ -0,0 +1,20 @@
export type { WriteParams, WriteSummary, AgentDef } from "./writers/utils.ts";
import type { AgentDef } from "./writers/utils.ts";
import claudeCode from "./writers/claude-code.ts";
import qwenCode from "./writers/qwen-code.ts";
import opencode from "./writers/opencode.ts";
import openclaw from "./writers/openclaw.ts";
import hermes from "./writers/hermes.ts";
import codex from "./writers/codex.ts";
export const AGENTS: Record<string, AgentDef> = {
"claude-code": claudeCode,
"qwen-code": qwenCode,
opencode,
openclaw,
hermes,
codex,
};
export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]];
@@ -0,0 +1,37 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
export default {
label: "Claude Code",
write({ baseUrl, apiKey, model }) {
const settingsPath = join(homedir(), ".claude", "settings.json");
const onboardingPath = join(homedir(), ".claude.json");
// 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.
backup(settingsPath);
const settings = readJson(settingsPath);
const env = (settings.env ?? {}) as Record<string, string>;
env.ANTHROPIC_BASE_URL = baseUrl;
env.ANTHROPIC_AUTH_TOKEN = apiKey;
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;
settings.env = env;
writeJsonAtomic(settingsPath, settings);
// .claude.json — skip the onboarding prompt on first launch.
backup(onboardingPath);
const onboarding = readJson(onboardingPath);
onboarding.hasCompletedOnboarding = true;
writeJsonAtomic(onboardingPath, onboarding);
return {
paths: [settingsPath, onboardingPath],
nextStep: "Run `claude` to start using Claude Code with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,56 @@
import { homedir } from "os";
import { join } from "path";
import { existsSync, readFileSync } from "fs";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
const PROVIDER_KEY = "bailian-cli";
export default {
label: "Codex",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".codex", "config.toml");
// config.toml — merge into existing config so unrelated settings
// (mcp_servers, approval_policy, other providers, ...) are preserved.
backup(configPath);
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
config = {};
}
}
config.model_provider = PROVIDER_KEY;
config.model = model;
config.model_reasoning_effort = "high";
config.disable_response_storage = true;
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
providers[PROVIDER_KEY] = {
...existing,
name: PROVIDER_KEY,
base_url: baseUrl,
wire_api: "responses",
requires_openai_auth: true,
};
config.model_providers = providers;
writeTextAtomic(configPath, stringifyToml(config) + "\n");
// auth.json — Codex reads OPENAI_API_KEY from here.
const authPath = join(homedir(), ".codex", "auth.json");
backup(authPath);
const auth = readJson(authPath);
auth.OPENAI_API_KEY = apiKey;
writeJsonAtomic(authPath, auth);
return {
paths: [configPath, authPath],
nextStep: "Run `codex` to start using Codex with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,53 @@
import { homedir } from "os";
import { join } from "path";
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 }) {
const configPath = join(homedir(), ".hermes", "config.yaml");
backup(configPath);
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
} catch {
config = {};
}
}
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
const providerEntry = {
name: PROVIDER_NAME,
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 };
writeTextAtomic(configPath, yaml.stringify(config));
return {
paths: [configPath],
nextStep: 'Run `hermes chat -q "hello"` to verify.',
};
},
} satisfies AgentDef;
@@ -0,0 +1,48 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
export default {
label: "OpenClaw",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".openclaw", "openclaw.json");
backup(configPath);
const config = readJson(configPath);
// models.providers["bailian-cli"]
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"] = {
baseUrl,
apiKey,
api,
models: [
{
id: model,
name: model,
contextWindow: 1000000,
cost: { input: 0, output: 0 },
},
],
};
models.providers = providers;
config.models = models;
// agents.defaults
const agents = (config.agents ?? {}) as Record<string, unknown>;
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
defaults.model = { primary: `bailian-cli/${model}` };
agents.defaults = defaults;
config.agents = agents;
writeJsonAtomic(configPath, config);
return {
paths: [configPath],
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,32 @@
import { homedir } from "os";
import { join } from "path";
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
export default {
label: "OpenCode",
write({ baseUrl, apiKey, model }) {
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
backup(configPath);
const config = readJson(configPath);
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
const provider = (config.provider ?? {}) as Record<string, unknown>;
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
provider["bailian-cli"] = {
npm,
name: "Alibaba Cloud Model Studio",
options: { baseURL: baseUrl, apiKey, setCacheKey: true },
models: { [model]: { name: model } },
};
config.provider = provider;
writeJsonAtomic(configPath, config);
return {
paths: [configPath],
nextStep: "Run `opencode` then type `/models` to select your model.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,61 @@
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";
/**
* 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.
*/
export default {
label: "Qwen Code",
write({ baseUrl, apiKey, model }) {
const settingsPath = join(homedir(), ".qwen", "settings.json");
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
backup(settingsPath);
const settings = readJson(settingsPath);
// env — API key read by the provider entry's envKey.
const env = (settings.env ?? {}) as Record<string, string>;
env[ENV_KEY] = apiKey;
settings.env = env;
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
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;
} else {
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
}
providers[protocol] = entries;
settings.modelProviders = providers;
// security.auth — select the protocol and carry the OpenAI-compatible creds.
const security = (settings.security ?? {}) as Record<string, unknown>;
security.auth = { selectedType: protocol, apiKey, baseUrl };
settings.security = security;
// model — active model, disambiguated by baseUrl.
settings.model = { name: model, baseUrl };
writeJsonAtomic(settingsPath, settings);
return {
paths: [settingsPath],
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
};
},
} satisfies AgentDef;
@@ -0,0 +1,59 @@
import { dirname } from "path";
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
/** Parameters shared by every agent writer. */
export interface WriteParams {
baseUrl: string;
apiKey: string;
model: string;
}
/** What a writer reports back after configuring an agent. */
export interface WriteSummary {
paths: string[];
nextStep: string;
}
/** An agent configuration writer: a human label plus a `write` that applies it. */
export interface AgentDef {
label: string;
write(params: WriteParams): WriteSummary;
}
/** Read a JSON object file, returning `{}` when missing or unparseable. */
export function readJson(path: string): Record<string, unknown> {
if (!existsSync(path)) return {};
try {
return JSON.parse(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 });
const tmp = path + ".tmp";
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
}
/** Atomically write raw text with owner-only permissions. */
export function writeTextAtomic(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = path + ".tmp";
writeFileSync(tmp, content, { mode: 0o600 });
renameSync(tmp, path);
}
/** Copy an existing file to a timestamped `.bak.<epoch>` sibling. No-op if absent. */
export function backup(path: string): void {
if (!existsSync(path)) return;
const timestamp = Math.floor(Date.now() / 1000);
copyFileSync(path, `${path}.bak.${timestamp}`);
}
/** Whether a base URL targets the Anthropic-messages compatible endpoint. */
export function isAnthropicEndpoint(baseUrl: string): boolean {
return baseUrl.includes("/apps/anthropic");
}
@@ -0,0 +1,31 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "List config profiles and show the active profile",
auth: "none",
exampleArgs: ["", "--output json"],
async run(ctx) {
const profiles = ctx.configStore.profiles();
const names = ["default", ...Object.keys(profiles.named).sort()];
const format = detectOutputFormat(ctx.settings.output);
if (format === "json") {
emitResult(
{
active_config: profiles.active,
profiles: names,
config_file: ctx.configStore.path,
},
format,
);
return;
}
const nameWidth = Math.max("NAME".length, ...names.map((name) => name.length));
emitBare(`${"NAME".padEnd(nameWidth)} ACTIVE`);
for (const name of names) {
emitBare(`${name.padEnd(nameWidth)} ${name === profiles.active ? "*" : ""}`);
}
},
});
+22 -77
View File
@@ -1,50 +1,6 @@
import {
defineCommand,
detectOutputFormat,
maskToken,
BailianError,
ExitCode,
type ConfigFile,
} from "bailian-cli-core";
import { defineCommand, detectOutputFormat, maskToken, type ConfigFile } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const VALID_KEYS = [
"base_url",
"output",
"output_dir",
"timeout",
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"default_text_model",
"default_video_model",
"default_image_model",
"default_speech_model",
"default_omni_model",
"workspace_id",
];
// Keys whose values are secrets. Their stored value must never be echoed back in
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
// same policy `config show` and `auth status` already follow.
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
"output-dir": "output_dir",
"api-key": "api_key",
"access-token": "access_token",
"access-key-id": "access_key_id",
"access-key-secret": "access_key_secret",
"default-text-model": "default_text_model",
"default-video-model": "default_video_model",
"default-image-model": "default_image_model",
"default-speech-model": "default_speech_model",
"default-omni-model": "default_omni_model",
"workspace-id": "workspace_id",
};
import { SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
export default defineCommand({
description: "Set a config value",
@@ -55,7 +11,7 @@ export default defineCommand({
type: "string",
valueHint: "<key>",
description:
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default_*_model, workspace_id)",
"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 },
@@ -70,47 +26,36 @@ export default defineCommand({
const key = flags.key;
const value = flags.value;
// Resolve hyphen aliases to underscore keys
const resolvedKey: string = KEY_ALIASES[key] || key;
if (!VALID_KEYS.includes(resolvedKey)) {
throw new BailianError(
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
ExitCode.USAGE,
);
}
// Validate specific values
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
ExitCode.USAGE,
);
}
if (resolvedKey === "timeout") {
const num = Number(value);
if (isNaN(num) || num <= 0) {
throw new BailianError(
`Invalid timeout "${value}". Must be a positive number.`,
ExitCode.USAGE,
);
}
}
// Resolve hyphen aliases to underscore keys and validate/coerce the value.
const resolvedKey: string = resolveKey(key);
const coerced = validateAndCoerce(key, value);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ would_set: { [resolvedKey]: value } }, format);
emitResult(
{
would_set: { [resolvedKey]: coerced },
config: settings.configName ?? "default",
config_file: ctx.configStore.path,
},
format,
);
return;
}
const coerced = resolvedKey === "timeout" ? Number(value) : value;
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
if (!settings.quiet) {
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
emitResult({ [resolvedKey]: shown }, format);
emitResult(
{
[resolvedKey]: shown,
config: settings.configName ?? "default",
config_file: ctx.configStore.path,
},
format,
);
}
},
});
@@ -0,0 +1,94 @@
import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core";
/** Config keys that `config set` / `config ui` accept for read/write. */
export const VALID_KEYS = [
"base_url",
"output",
"output_dir",
"timeout",
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"security_token",
"default_text_model",
"default_video_model",
"default_image_to_video_model",
"default_reference_to_video_model",
"default_image_model",
"default_speech_model",
"default_omni_model",
"workspace_id",
] as const;
// Keys whose values are secrets. `config set` / `config show` mask these; the
// web UI renders them as password fields (values are still sent in cleartext
// over the token-gated localhost socket).
export const SECRET_KEYS = new Set<string>([
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"security_token",
]);
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
export const KEY_ALIASES: Record<string, string> = {
"base-url": "base_url",
"output-dir": "output_dir",
"api-key": "api_key",
"access-token": "access_token",
"access-key-id": "access_key_id",
"access-key-secret": "access_key_secret",
"security-token": "security_token",
"default-text-model": "default_text_model",
"default-video-model": "default_video_model",
"default-image-to-video-model": "default_image_to_video_model",
"default-reference-to-video-model": "default_reference_to_video_model",
"default-image-model": "default_image_model",
"default-speech-model": "default_speech_model",
"default-omni-model": "default_omni_model",
"workspace-id": "workspace_id",
};
/** Resolve a hyphen alias to its underscore config key. */
export function resolveKey(key: string): string {
return KEY_ALIASES[key] || key;
}
/**
* Validate a single config entry and coerce its value to the stored type.
* Throws BailianError(USAGE) for unknown keys or invalid values.
*/
export function validateAndCoerce(key: string, value: string): string | number {
const resolvedKey = resolveKey(key);
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
throw new BailianError(
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
ExitCode.USAGE,
);
}
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
throw new BailianError(
`Invalid output format "${value}". Valid values: text, json`,
ExitCode.USAGE,
);
}
if (resolvedKey === "timeout") {
const num = Number(value);
if (isNaN(num) || num <= 0) {
throw new BailianError(
`Invalid timeout "${value}". Must be a positive number.`,
ExitCode.USAGE,
);
}
return num;
}
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
return value;
}
@@ -1,5 +1,6 @@
import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { SECRET_KEYS } from "./shared.ts";
export default defineCommand({
description: "Display current configuration",
@@ -16,16 +17,13 @@ export default defineCommand({
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
config: settings.configName ?? "default",
config_file: store.path,
};
if (typeof result.api_key === "string") result.api_key = maskToken(result.api_key);
if (typeof result.access_token === "string")
result.access_token = maskToken(result.access_token);
if (typeof result.access_key_id === "string")
result.access_key_id = maskToken(result.access_key_id);
if (typeof result.access_key_secret === "string")
result.access_key_secret = maskToken(result.access_key_secret);
for (const key of SECRET_KEYS) {
if (typeof result[key] === "string") result[key] = maskToken(result[key]);
}
emitResult(result, format);
},
@@ -0,0 +1,266 @@
// Self-contained single-page web UI for managing config profiles. Served as a
// string by `config ui`; no build step, no client dependencies. All fetches
// carry the session token from the page URL.
export const PAGE_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>bailian-cli config</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1f2328; background: #f6f8fa; }
#app { display: flex; min-height: 100vh; }
#sidebar { width: 240px; background: #fff; border-right: 1px solid #d0d7de; padding: 16px; }
#sidebar h1 { font-size: 15px; margin: 0 0 12px; }
#profileList { list-style: none; margin: 0 0 12px; padding: 0; }
#profileList li { padding: 8px 10px; border-radius: 6px; cursor: pointer; word-break: break-all; }
#profileList li:hover { background: #f0f3f6; }
#profileList li.selected { background: #0969da; color: #fff; }
main { flex: 1; padding: 24px 32px; max-width: 720px; }
#editorHead { display: flex; align-items: center; justify-content: space-between; }
h2 { font-size: 18px; margin: 0 0 4px; }
.row { display: flex; flex-direction: column; margin: 12px 0; }
.row label { font-weight: 600; margin-bottom: 4px; }
.inputwrap { display: flex; gap: 6px; }
input { flex: 1; padding: 7px 9px; border: 1px solid #d0d7de; border-radius: 6px; font: inherit; width: 100%; }
button { padding: 7px 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; cursor: pointer; font: inherit; }
button:hover { background: #eef1f4; }
#saveBtn { background: #1f883d; color: #fff; border-color: #1f883d; }
#saveBtn:hover { background: #1a7f37; }
.danger { color: #cf222e; }
.toggle { flex: none; }
.actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
.muted { color: #656d76; font-size: 12px; word-break: break-all; }
.err { color: #cf222e; font-size: 12px; }
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<h1>Config Profiles</h1>
<ul id="profileList"></ul>
<button id="newBtn">+ New profile</button>
<p id="cfgFile" class="muted"></p>
</aside>
<main id="editor">
<div id="editorHead">
<h2 id="currentName"></h2>
<button id="deleteBtn" class="danger">Delete</button>
</div>
<form id="form" onsubmit="return false"></form>
<div class="actions">
<button id="saveBtn">Save</button>
<button id="useBtn">Save &amp; Activate</button>
<span id="status" class="muted"></span>
</div>
</main>
</div>
<script>
var token = new URLSearchParams(location.search).get('token') || '';
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '', ACTIVE = 'default';
function api(path, opts) {
var sep = path.indexOf('?') >= 0 ? '&' : '?';
return fetch(path + sep + 'token=' + encodeURIComponent(token), opts || {});
}
function setStatus(msg, isErr) {
var el = document.getElementById('status');
el.textContent = msg || '';
el.className = isErr ? 'err' : 'muted';
}
function profileData(name) {
return name === '' ? DATA.default : (DATA.named[name] || {});
}
function load() {
api('/api/config').then(function (r) { return r.json(); }).then(function (j) {
KEYS = j.keys || [];
SECRETS = j.secretKeys || [];
DATA = { default: j.default || {}, named: j.named || {} };
ACTIVE = j.activeProfile || 'default';
document.getElementById('cfgFile').textContent = j.configFile || '';
CURRENT = ACTIVE === 'default' ? '' : ACTIVE;
renderProfiles();
renderForm();
}).catch(function (e) { setStatus('Load failed: ' + e, true); });
}
function renderProfiles() {
var ul = document.getElementById('profileList');
ul.innerHTML = '';
var names = [''].concat(Object.keys(DATA.named));
names.forEach(function (name) {
var li = document.createElement('li');
var displayName = name === '' ? 'default' : name;
li.textContent = displayName + (displayName === ACTIVE ? ' *' : '');
if (name === CURRENT) li.className = 'selected';
li.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); };
ul.appendChild(li);
});
}
function renderForm() {
var form = document.getElementById('form');
form.innerHTML = '';
document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT;
document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : '';
var selectedName = CURRENT === '' ? 'default' : CURRENT;
var useBtn = document.getElementById('useBtn');
useBtn.disabled = selectedName === ACTIVE;
useBtn.textContent = selectedName === ACTIVE ? 'Active' : 'Save & Activate';
var data = profileData(CURRENT);
KEYS.forEach(function (key) {
var row = document.createElement('div');
row.className = 'row';
var label = document.createElement('label');
label.textContent = key;
label.htmlFor = 'f_' + key;
var input = document.createElement('input');
input.id = 'f_' + key;
input.name = key;
var val = data[key];
input.value = (val === undefined || val === null) ? '' : String(val);
if (SECRETS.indexOf(key) >= 0) {
input.type = 'password';
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'toggle';
toggle.textContent = 'show';
toggle.onclick = function () {
if (input.type === 'password') { input.type = 'text'; toggle.textContent = 'hide'; }
else { input.type = 'password'; toggle.textContent = 'show'; }
};
var wrap = document.createElement('div');
wrap.className = 'inputwrap';
wrap.appendChild(input);
wrap.appendChild(toggle);
row.appendChild(label);
row.appendChild(wrap);
} else {
input.type = 'text';
row.appendChild(label);
row.appendChild(input);
}
form.appendChild(row);
});
}
function collect() {
var data = {};
KEYS.forEach(function (key) {
var el = document.getElementById('f_' + key);
data[key] = el ? el.value : '';
});
return data;
}
function saveProfile(name, data) {
var profileData = data === undefined ? collect() : data;
var body = JSON.stringify({ name: name, data: profileData });
return api('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) {
throw new Error((result.json && result.json.error) || 'error');
}
var saved = result.json.saved || {};
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
return saved;
});
}
function save() {
var name = CURRENT;
saveProfile(name)
.then(function () {
renderForm();
setStatus('Saved.');
})
.catch(function (error) { setStatus('Save failed: ' + error.message, true); });
}
function newProfile() {
var name = prompt('New profile name (letters, numbers, - or _):');
if (!name) return;
if (DATA.named[name] !== undefined) {
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Profile already exists.');
return;
}
setStatus('Creating profile...');
saveProfile(name, {})
.then(function () {
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Profile created and saved.');
})
.catch(function (error) { setStatus('Create failed: ' + error.message, true); });
}
function saveAndActivateProfile() {
var name = CURRENT === '' ? 'default' : CURRENT;
var profileName = CURRENT;
setStatus('Saving...');
saveProfile(profileName)
.then(function () {
return api('/api/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
})
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) {
throw new Error('Saved, but activation failed: ' + ((result.json && result.json.error) || 'error'));
}
ACTIVE = result.json.activeProfile || 'default';
renderProfiles();
renderForm();
setStatus('Saved and activated.');
})
.catch(function (error) { setStatus(error.message || String(error), true); });
}
function deleteProfile() {
if (CURRENT === '') return;
if (!confirm('Delete profile "' + CURRENT + '"?')) return;
api('/api/profile?name=' + encodeURIComponent(CURRENT), { method: 'DELETE' })
.then(function (r) {
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'error'); });
return r.json();
})
.then(function (result) {
delete DATA.named[CURRENT];
ACTIVE = result.activeProfile || 'default';
CURRENT = '';
renderProfiles();
renderForm();
setStatus('Deleted.');
})
.catch(function (e) { setStatus('Delete failed: ' + e, true); });
}
document.getElementById('saveBtn').onclick = save;
document.getElementById('newBtn').onclick = newProfile;
document.getElementById('useBtn').onclick = saveAndActivateProfile;
document.getElementById('deleteBtn').onclick = deleteProfile;
load();
</script>
</body>
</html>
`;
+264
View File
@@ -0,0 +1,264 @@
import http from "node:http";
import { randomBytes } from "node:crypto";
import {
defineCommand,
detectOutputFormat,
BailianError,
ExitCode,
normalizeConfigName,
readConfigFile,
writeConfigFile,
deleteConfigProfile,
type ConfigStore,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
import { PAGE_HTML } from "./ui-html.ts";
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
const FLAGS = {
port: {
type: "number",
valueHint: "<port>",
description: "Port to listen on (default: random free port)",
},
noOpen: { type: "switch", description: "Do not open the browser automatically" },
} satisfies FlagsDef;
const MAX_BODY = 1 << 20; // 1 MiB
function errMessage(err: unknown): string {
return err instanceof BailianError
? err.message
: err instanceof Error
? err.message
: String(err);
}
function sendJson(res: http.ServerResponse, status: number, obj: unknown): void {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(obj));
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > MAX_BODY) {
reject(new Error("payload too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
/** 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> = {};
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);
}
return cleaned;
}
/** Preserve valid Config fields that the UI does not expose or manage. */
function mergeUnmanagedProfileFields(
existing: Record<string, unknown>,
managedPatch: Record<string, string | number>,
): Record<string, unknown> {
const managedKeys = new Set<string>(VALID_KEYS);
const merged: Record<string, unknown> = {};
for (const [key, value] of Object.entries(existing)) {
if (!managedKeys.has(key)) merged[key] = value;
}
return { ...merged, ...managedPatch };
}
/**
* Build the config-UI http server. Exported for tests. The handler enforces:
* - 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 {
return http.createServer(async (req, res) => {
try {
const host = (req.headers.host || "").split(":")[0];
if (host !== "127.0.0.1" && host !== "localhost") {
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
res.end("forbidden host\n");
return;
}
const u = new URL(req.url ?? "/", "http://127.0.0.1");
if (u.searchParams.get("token") !== token) {
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
res.end("unauthorized\n");
return;
}
const method = req.method ?? "GET";
const path = u.pathname;
if (path === "/" && method === "GET") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE_HTML);
return;
}
if (path === "/api/config" && method === "GET") {
const profiles = configStore.profiles();
sendJson(res, 200, {
configFile: configStore.path,
keys: VALID_KEYS,
secretKeys: [...SECRET_KEYS],
activeProfile: profiles.active,
default: profiles.default,
named: profiles.named,
});
return;
}
if (path === "/api/active" && 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 { name?: unknown };
try {
const activeProfile = await configStore.activate(body.name);
sendJson(res, 200, { activeProfile });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/profile" && 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 { name?: unknown; data?: unknown };
if (!body.data || typeof body.data !== "object" || Array.isArray(body.data)) {
sendJson(res, 400, { error: "missing or invalid 'data'" });
return;
}
let normalized: string | undefined;
let cleaned: Record<string, string | number>;
try {
normalized = normalizeConfigName(body.name);
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
return;
}
const existing = readConfigFile(normalized) as Record<string, unknown>;
const saved = mergeUnmanagedProfileFields(existing, cleaned);
await writeConfigFile(saved, normalized);
sendJson(res, 200, { saved });
return;
}
if (path === "/api/profile" && method === "DELETE") {
try {
const deleted = await deleteConfigProfile(u.searchParams.get("name") ?? undefined);
sendJson(res, 200, { deleted, activeProfile: configStore.profiles().active });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found\n");
} catch {
if (!res.headersSent) res.writeHead(500);
res.end();
}
});
}
export default defineCommand({
description: "Open a local web UI to manage config profiles",
auth: "none",
usageArgs: "[--port <port>] [--no-open]",
flags: FLAGS,
exampleArgs: ["", "--port 8787", "--no-open"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
host: "127.0.0.1",
port: flags.port ?? "random free port",
config_file: ctx.configStore.path,
routes: [
"GET / -> web UI",
"GET /api/config -> read all profiles",
"POST /api/profile -> save a profile",
"POST /api/active -> activate a profile",
"DELETE /api/profile -> delete a named profile",
],
},
format,
);
return;
}
const token = randomBytes(16).toString("hex");
const server = createConfigUiServer(token, ctx.configStore);
let port: number;
try {
port = await listenLocalServer(server, flags.port ?? 0);
} catch (err) {
throw new BailianError(
`Could not bind to 127.0.0.1 (no free port or permission denied): ${errMessage(err)}`,
ExitCode.USAGE,
);
}
const url = `http://127.0.0.1:${port}/?token=${token}`;
if (!flags.noOpen) {
try {
await openInBrowser(url);
emitBare("Opened the config UI in your default browser.");
} catch {
emitBare("Could not open the browser automatically. Open the URL below manually.");
}
}
emitBare(`Config UI running at ${url}`);
emitBare("Note: credentials are shown in cleartext in the browser (localhost only).");
emitBare("Press Ctrl+C to stop.");
await new Promise<void>((resolve) => {
const shutdown = () => server.close(() => resolve());
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
server.once("close", () => resolve());
});
},
});
@@ -0,0 +1,42 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Set the active config profile",
auth: "none",
usageArgs: "--name <name>",
flags: {
name: {
type: "string",
valueHint: "<name>",
description: "Existing profile name, or default",
required: true,
},
},
exampleArgs: ["--name token-plan", "--name default"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
const activeConfig = ctx.configStore.validateActivation(ctx.flags.name);
emitResult(
{
would_activate: activeConfig,
config_file: ctx.configStore.path,
},
format,
);
return;
}
const activeConfig = await ctx.configStore.activate(ctx.flags.name);
if (!ctx.settings.quiet) {
emitResult(
{
active_config: activeConfig,
config_file: ctx.configStore.path,
},
format,
);
}
},
});
+24 -6
View File
@@ -22,6 +22,7 @@ import {
resolveWatermark,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
import { downloadFile } from "bailian-cli-runtime";
@@ -31,10 +32,15 @@ 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"];
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((p) => model.startsWith(p));
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 = {
@@ -98,7 +104,7 @@ const EDIT_FLAGS = {
type EditFlags = ParsedFlags<typeof EDIT_FLAGS>;
export default defineCommand({
description: "Edit an existing image with text instructions (Qwen-Image)",
description: "Edit an existing image with text instructions (Qwen-Image / Wan 2.7)",
auth: "apiKey",
usageArgs: "--image <url> --prompt <text> [flags]",
flags: EDIT_FLAGS,
@@ -107,6 +113,7 @@ export default defineCommand({
'--image https://example.com/logo.png --prompt "Change color to blue" --n 3',
'--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 "Replace the background with a beach" --watermark false',
],
async run(ctx) {
@@ -125,13 +132,13 @@ export default defineCommand({
// Auto-upload local files (resolve all images in parallel)
const resolvedImages = await Promise.all(
rawImages.map((img) => ctx.client.uploadFile(img, model)),
rawImages.map((image) => ctx.client.resolveImageInput(image, model)),
);
const n = flags.n ?? 1;
const promptExtend = resolveBooleanFlag(
flags.promptExtend,
useSync ? true : undefined,
enablesPromptExtendByDefault(model) ? true : undefined,
"prompt-extend",
);
@@ -169,7 +176,18 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
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);
return;
}
@@ -31,11 +31,16 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
import { join } from "path";
// qwen-image-2.0 series uses the sync multimodal-generation endpoint
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
// 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((p) => model.startsWith(p));
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 = {
@@ -121,7 +126,7 @@ export default defineCommand({
const promptExtend = resolveBooleanFlag(
flags.promptExtend,
useSync ? true : undefined,
enablesPromptExtendByDefault(model) ? true : undefined,
"prompt-extend",
);
@@ -0,0 +1,37 @@
import { execFile } from "node:child_process";
import http from "node:http";
/**
* Bind an http server to a loopback-only TCP port and resolve the chosen port.
* `port = 0` (default) lets the OS pick a free port. Always binds 127.0.0.1 so
* the server is never reachable off the local machine.
*/
export function listenLocalServer(server: http.Server, port = 0): Promise<number> {
return new Promise((resolve, reject) => {
const onErr = (e: Error) => reject(e);
server.once("error", onErr);
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
server.off("error", onErr);
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Expected TCP socket address"));
return;
}
resolve(addr.port);
});
});
}
/** Open a URL in the user's default browser (best-effort, cross-platform). */
export function openInBrowser(url: string): Promise<void> {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
+2 -3
View File
@@ -3,6 +3,7 @@ import {
chatPath,
parseSSE,
detectOutputFormat,
readTextFromPathOrStdin,
type ChatMessage,
type ChatRequest,
type ChatResponse,
@@ -69,9 +70,7 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
}
if (flags.messagesFile) {
const filePath = flags.messagesFile;
const raw =
filePath === "-" ? readFileSync("/dev/stdin", "utf-8") : readFileSync(filePath, "utf-8");
const raw = readTextFromPathOrStdin(flags.messagesFile);
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
for (const m of parsed) {
if (m.role === "system") {
@@ -13,6 +13,7 @@ import {
resolveWatermark,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
import { downloadFile, formatBytes } from "bailian-cli-runtime";
@@ -103,8 +104,9 @@ export default defineCommand({
const model =
flags.model ||
settings.defaultVideoModel ||
(flags.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
(flags.image
? settings.defaultImageToVideoModel || "happyhorse-1.1-i2v"
: settings.defaultVideoModel || "happyhorse-1.1-t2v");
const format = detectOutputFormat(settings.output);
const imageUrl = flags.image;
@@ -112,7 +114,7 @@ export default defineCommand({
// Auto-upload local image file for i2v
let resolvedImageUrl: string | undefined;
if (imageUrl) {
resolvedImageUrl = await ctx.client.uploadFile(imageUrl, model);
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
}
const watermark = resolveWatermark(flags.watermark);
@@ -139,7 +141,16 @@ export default defineCommand({
};
if (settings.dryRun) {
emitResult({ request: body }, format);
const previewBody = resolvedImageUrl
? {
...body,
input: {
...body.input,
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
},
}
: body;
emitResult({ request: previewBody }, format);
return;
}
+25 -13
View File
@@ -13,6 +13,7 @@ import {
resolveWatermark,
ASYNC_FLAG,
CONCURRENT_FLAG,
redactDataUri,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
import { downloadFile, formatBytes } from "bailian-cli-runtime";
@@ -117,23 +118,23 @@ export default defineCommand({
const imageVoices = flags.imageVoice || [];
const videoVoices = flags.videoVoice || [];
const model = flags.model || "happyhorse-1.1-r2v";
const model = flags.model || settings.defaultReferenceToVideoModel || "happyhorse-1.1-r2v";
const format = detectOutputFormat(settings.output);
// --- Resolve file URLs (auto-upload local files) ---
const media: DashScopeVideoRefRequest["input"]["media"] = [];
// Add reference images
for (let i = 0; i < images.length; i++) {
const resolved = await ctx.client.uploadFile(images[i]!, model);
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
const resolved = await ctx.client.resolveImageInput(images[imageIndex]!, model);
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
type: "reference_image",
url: resolved,
};
// Pair voice by position
if (imageVoices[i]) {
const resolvedVoice = await ctx.client.uploadFile(imageVoices[i]!, model);
if (imageVoices[imageIndex]) {
const resolvedVoice = await ctx.client.uploadFile(imageVoices[imageIndex]!, model);
entry.reference_voice = resolvedVoice;
}
@@ -141,16 +142,16 @@ export default defineCommand({
}
// Add reference videos
for (let i = 0; i < refVideos.length; i++) {
const resolved = await ctx.client.uploadFile(refVideos[i]!, model);
for (let videoIndex = 0; videoIndex < refVideos.length; videoIndex++) {
const resolved = await ctx.client.uploadFile(refVideos[videoIndex]!, model);
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
type: "reference_video",
url: resolved,
};
// Pair voice by position
if (videoVoices[i]) {
const resolvedVoice = await ctx.client.uploadFile(videoVoices[i]!, model);
if (videoVoices[videoIndex]) {
const resolvedVoice = await ctx.client.uploadFile(videoVoices[videoIndex]!, model);
entry.reference_voice = resolvedVoice;
}
@@ -178,7 +179,18 @@ export default defineCommand({
};
if (settings.dryRun) {
emitResult({ request: body }, format);
const previewBody = {
...body,
input: {
...body.input,
media: body.input.media.map((item) => ({
...item,
url: redactDataUri(item.url),
reference_voice: item.reference_voice ? redactDataUri(item.reference_voice) : undefined,
})),
},
};
emitResult({ request: previewBody }, format);
return;
}
@@ -233,11 +245,11 @@ export default defineCommand({
);
const videos: Array<{ taskId: string; videoUrl: string }> = [];
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
const result = results[resultIndex]!;
const videoUrl =
result.output.video_url || (result.output.results && result.output.results[0]?.url);
if (videoUrl) videos.push({ taskId: taskIds[i]!, videoUrl });
if (videoUrl) videos.push({ taskId: taskIds[resultIndex]!, videoUrl });
}
if (videos.length === 0) {
@@ -8,18 +8,13 @@ import {
BailianError,
ExitCode,
isLocalFile,
imageFileToDataUri,
redactDataUri,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { readFileSync, existsSync } from "fs";
import { existsSync, statSync } from "fs";
import { extname } from "path";
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
function isVideoInput(input: string): boolean {
@@ -35,18 +30,7 @@ async function toImageUrl(image: string): Promise<string> {
if (image.startsWith("data:")) return image;
if (image.startsWith("http://") || image.startsWith("https://")) return image;
if (image.startsWith("oss://")) return image;
// Local file → data URI (for small files < 10MB, fallback)
if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE);
const ext = extname(image).toLowerCase();
const mime = IMAGE_MIME_TYPES[ext];
if (!mime)
throw new BailianError(
`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`,
ExitCode.USAGE,
);
const buf = readFileSync(image);
return `data:${mime};base64,${buf.toString("base64")}`;
return imageFileToDataUri(image);
}
export default defineCommand({
@@ -86,7 +70,10 @@ export default defineCommand({
const { settings, flags } = ctx;
let image = flags.image;
const videoInputs = flags.video ?? [];
const model = flags.model || "qwen3-vl-plus";
const model =
flags.model ||
(ctx.client.usesTokenPlanEndpoint() ? settings.defaultTextModel : undefined) ||
"qwen3-vl-plus";
// Auto-detect: if --image was given a video file, treat it as --video
if (image && isVideoInput(image)) {
@@ -102,7 +89,14 @@ export default defineCommand({
if (settings.dryRun) {
emitResult(
{ request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } },
{
request: {
prompt,
image: image ? redactDataUri(image) : undefined,
video: videoInputs.length ? videoInputs.map(redactDataUri) : undefined,
model,
},
},
format,
);
return;
@@ -132,10 +126,9 @@ export default defineCommand({
let finalImageUrl = imageUrl;
if (isLocalFile(image) && imageUrl.startsWith("data:")) {
const { statSync } = await import("fs");
const fileSize = statSync(image).size;
if (fileSize > 5 * 1024 * 1024) {
finalImageUrl = await ctx.client.uploadFile(image, model);
finalImageUrl = await ctx.client.resolveImageInput(image, model);
}
}
@@ -0,0 +1,334 @@
import {
defineCommand,
detectOutputFormat,
BailianError,
ExitCode,
generateCLIAccessToken,
callConsoleGateway,
effectiveConsoleGatewayConfig,
createBailianControlUser,
listBailianControlWorkspaces,
resetBailianControlPolicies4Agent,
type ConsoleGatewayTarget,
type FlagsDef,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const API = {
loginInfo: "zeldaEasy.cornerstone-portal.cs-console.loginInfo",
initSpace: "zeldaEasy.bailian-dash-workspace.space.initSpace",
queryBuyResult: "zeldaEasy.bailian-commerce.bill.queryBuyPostpaidResult",
commodityOrderInfo: "zeldaEasy.bailian-commerce.bill.postpaidCommodityOrderInfo",
buyCommodity: "zeldaEasy.bailian-commerce.bill.buyPostpaidCommodity",
} as const;
const FLAGS = {
accessKeyId: {
type: "string",
valueHint: "<id>",
description: "Alibaba Cloud Access Key ID",
},
accessKeySecret: {
type: "string",
valueHint: "<secret>",
description: "Alibaba Cloud Access Key Secret",
},
securityToken: {
type: "string",
valueHint: "<token>",
description: "Alibaba Cloud STS Security Token (optional)",
},
} satisfies FlagsDef;
const POLL_INTERVAL_MS = 1000;
const MAX_POLL_ATTEMPTS = 20;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function extractData(resp: any): any {
return resp?.data?.DataV2?.data?.data;
}
/**
* Resolve the agent id from a ListWorkspaces response. Workspaces live under
* `data.data`; the agent id is the workspace's `tenantId`. Prefer the default
* workspace (`defaultAgent`), else fall back to the first one.
*/
function extractAgentId(resp: any): number | undefined {
const workspaces = resp?.data?.data;
if (!Array.isArray(workspaces) || workspaces.length === 0) return undefined;
const chosen = workspaces.find((workspace) => workspace?.defaultAgent === true) ?? workspaces[0];
const tenantId = chosen?.tenantId;
const agentId = typeof tenantId === "string" ? Number(tenantId) : tenantId;
return typeof agentId === "number" && Number.isFinite(agentId) ? agentId : undefined;
}
interface CommodityItem {
commodityCode?: string;
status?: number;
}
export default defineCommand({
description: "Initialize Bailian workspace and activate postpaid services",
auth: "none",
usageArgs: "--access-key-id <id> --access-key-secret <secret> [--security-token <token>]",
flags: FLAGS,
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx"],
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
apis: [
{
step: 0,
api: "GenerateCLIAccessToken",
description: "Generate CLI access token from AK/SK",
},
{
step: 1,
api: API.loginInfo,
description: "Check login & workspace status",
},
{
step: 2,
api: API.initSpace,
description: "Initialize workspace (if needed)",
},
{
step: 3,
api: "CreateUser",
description: "Create console user via BailianControl OpenAPI (CreateUser)",
},
{
step: 4,
api: "ListWorkspaces",
description: "List workspaces to resolve agentId",
},
{
step: 5,
api: "ResetPolicies4Agent",
description: "Authorize user permissions",
},
{
step: 6,
api: API.queryBuyResult,
description: "Query postpaid order status",
},
{
step: 7,
api: API.commodityOrderInfo,
description: "Query commodity activation status",
},
{
step: 8,
api: API.buyCommodity,
description: "Activate postpaid commodities (if needed)",
},
],
},
format,
);
return;
}
const { accessKeyId, accessKeySecret } = flags;
if (!accessKeyId || !accessKeySecret) {
throw new BailianError(
"workspace init requires --access-key-id and --access-key-secret.",
ExitCode.USAGE,
);
}
const securityToken = flags.securityToken || undefined;
// Step 0: Exchange AK/SK for a temporary CLI access token used by console calls.
const tokenResp = await generateCLIAccessToken({
identity: ctx.identity,
settings,
baseUrl: ctx.client.baseUrl,
accessKeyId,
accessKeySecret,
securityToken,
});
const accessToken: string | undefined = tokenResp.cliAccessToken;
if (!accessToken) {
throw new BailianError("Failed to generate CLI access token from AK/SK.", ExitCode.GENERAL);
}
const gateway = effectiveConsoleGatewayConfig(settings);
const target: ConsoleGatewayTarget = {
region: gateway.consoleRegion,
site: gateway.consoleSite,
...(gateway.consoleSwitchAgent != null ? { switchAgent: gateway.consoleSwitchAgent } : {}),
token: accessToken,
};
const verbose = settings.verbose;
const callApi = async (api: string, data: Record<string, unknown> = {}) => {
if (verbose) process.stderr.write(`> ${api}\n`);
try {
const resp = await callConsoleGateway(target, settings.timeout, { api, data }, settings);
if (verbose) process.stderr.write(`< ${JSON.stringify(resp)}\n`);
return resp;
} catch (err) {
if (verbose) {
const message =
err instanceof BailianError ? (err.rawResponse ?? err.message) : String(err);
process.stderr.write(`< ERROR: ${message}\n`);
}
throw err;
}
};
// Step 1: Check login info
emitBare("Checking workspace status...");
const loginResp = await callApi(API.loginInfo);
const loginData = extractData(loginResp);
const spaceInited = loginData?.spaceInited === true;
// Step 2: Init space if needed
if (!spaceInited) {
emitBare("Initializing workspace...");
await callApi(API.initSpace);
emitBare("Workspace initialized.");
} else {
emitBare("Workspace already initialized.");
}
// Step 3: Create console user via BailianControl OpenAPI (AK/SK signed)
const uid = loginData?.aliyun?.uid;
if (typeof uid !== "string" || uid.length === 0) {
throw new BailianError("Console login info did not include aliyun.uid.", ExitCode.GENERAL);
}
const bailianControlAuth = {
identity: ctx.identity,
settings,
baseUrl: ctx.client.baseUrl,
regionId: gateway.consoleRegion,
accessKeyId,
accessKeySecret,
securityToken,
};
try {
await createBailianControlUser({
...bailianControlAuth,
reqDTO: {
outerKey: uid,
nickName: uid,
userName: uid,
},
});
} catch (err) {
// Re-running workspace init is idempotent: an already-existing user is
// not fatal, so swallow it and continue with the remaining steps.
if (!(err instanceof BailianError) || !/already exists/i.test(err.message)) {
throw err;
}
emitBare("Console user already exists, continuing.");
}
// Step 4-5: Resolve the workspace agent id, then authorize user permissions.
emitBare("Resolving workspace agent...");
const workspacesResp = await listBailianControlWorkspaces(bailianControlAuth);
const agentId = extractAgentId(workspacesResp);
if (agentId == null) {
throw new BailianError(
"Could not resolve agentId from ListWorkspaces response.",
ExitCode.GENERAL,
"Re-run with --verbose to inspect the ListWorkspaces response body.",
);
}
emitBare("Authorizing user permissions...");
await resetBailianControlPolicies4Agent({
...bailianControlAuth,
outerKey: uid,
agentId,
policyIndexList: [1],
});
// Step 6-8: Order & commodity flow
await ensureCommoditiesActive(callApi, format);
},
});
type ApiCall = (api: string, data?: Record<string, unknown>) => Promise<any>;
async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise<void> {
emitBare("Checking service activation status...");
let buyResult: string | undefined;
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
const resp = await call(API.queryBuyResult);
const data = extractData(resp);
buyResult = typeof data === "string" ? data : data?.result;
if (buyResult !== "buying") break;
if (i === 0) emitBare("Service activation in progress, polling...");
await sleep(POLL_INTERVAL_MS);
}
if (buyResult === "fail") {
throw new BailianError("Service activation failed.", ExitCode.GENERAL);
}
if (buyResult === "success") {
await pollCommoditiesUntilActive(call, format);
return;
}
await checkAndActivateCommodities(call, format);
}
function extractCommodities(resp: any): CommodityItem[] {
const data = extractData(resp);
return Array.isArray(data) ? data : [];
}
async function checkAndActivateCommodities(call: ApiCall, format: "text" | "json"): Promise<void> {
const resp = await call(API.commodityOrderInfo);
const items = extractCommodities(resp);
const overdue = items.filter((c) => c.status === 11);
if (overdue.length > 0) {
emitBare("Warning: Some services are overdue:");
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
}
const notActivated = items.filter((c) => c.status === 1);
if (notActivated.length > 0) {
emitBare(`Activating ${notActivated.length} postpaid services...`);
await call(API.buyCommodity);
await pollCommoditiesUntilActive(call, format);
return;
}
const active = items.filter((c) => c.status === 10);
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
}
async function pollCommoditiesUntilActive(call: ApiCall, format: "text" | "json"): Promise<void> {
emitBare("Waiting for services to activate...");
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
const resp = await call(API.commodityOrderInfo);
const items = extractCommodities(resp);
const pending = items.filter((c) => c.status !== 10 && c.status !== 11);
if (pending.length === 0) {
const overdue = items.filter((c) => c.status === 11);
if (overdue.length > 0) {
emitBare("Warning: Some services are overdue:");
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
}
const active = items.filter((c) => c.status === 10);
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
return;
}
await sleep(POLL_INTERVAL_MS);
}
throw new BailianError("Timed out waiting for services to activate.", ExitCode.TIMEOUT);
}
+6
View File
@@ -6,6 +6,7 @@
export { default as authLogin } from "./commands/auth/login.ts";
export { default as authStatus } from "./commands/auth/status.ts";
export { default as authLogout } from "./commands/auth/logout.ts";
export { default as authGenerateAccessToken } from "./commands/auth/generate-access-token.ts";
export { default as textChat } from "./commands/text/chat.ts";
export { default as textOmni } from "./commands/omni/chat.ts";
export { default as imageGenerate } from "./commands/image/generate.ts";
@@ -18,6 +19,10 @@ export { default as videoDownload } from "./commands/video/download.ts";
export { default as visionDescribe } from "./commands/vision/describe.ts";
export { default as configShow } from "./commands/config/show.ts";
export { default as configSet } from "./commands/config/set.ts";
export { default as configList } from "./commands/config/list.ts";
export { default as configUse } from "./commands/config/use.ts";
export { default as configUi } from "./commands/config/ui.ts";
export { default as configAgent } from "./commands/config/agent/index.ts";
export { default as update } from "./commands/update.ts";
export { default as appCall } from "./commands/app/call.ts";
export { default as appList } from "./commands/app/list.ts";
@@ -86,6 +91,7 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as workspaceInit } from "./commands/workspace/init.ts";
export { default as pluginInstall } from "./commands/plugin/install.ts";
export { default as pluginLink } from "./commands/plugin/link.ts";
export { default as pluginList } from "./commands/plugin/list.ts";
@@ -0,0 +1,239 @@
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
import { tmpdir, homedir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
import claudeCode from "../src/commands/config/agent/writers/claude-code.ts";
import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts";
import opencode from "../src/commands/config/agent/writers/opencode.ts";
import openclaw from "../src/commands/config/agent/writers/openclaw.ts";
import hermes from "../src/commands/config/agent/writers/hermes.ts";
import codex from "../src/commands/config/agent/writers/codex.ts";
import yaml from "yaml";
/**
* Agent writer 单元测试:直接调用 writer用临时 HOME 隔离文件系统。
* writer 是纯文件 I/O在进程内测试比 e2e 子进程更快、覆盖更全。
*/
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
let home = "";
let prevHome: string | undefined;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
prevHome = process.env.HOME;
process.env.HOME = home;
// homedir() 在 POSIX 读 $HOME断言隔离生效。
expect(homedir()).toBe(home);
});
afterEach(() => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
function readJsonAt(...segments: string[]): Record<string, unknown> {
return JSON.parse(readFileSync(join(home, ...segments), "utf8"));
}
describe("config agent writers", () => {
test("claude-code 写入 env 与 onboarding并合并已有 env", () => {
// 预置一个无关 env 键,验证合并保留
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(
join(home, ".claude", "settings.json"),
JSON.stringify({ env: { KEEP_ME: "1" }, other: true }),
);
const summary = claudeCode.write({
baseUrl: ANTHROPIC_URL,
apiKey: "sk-a",
model: "qwen3-max",
});
expect(summary.paths).toHaveLength(2);
const settings = readJsonAt(".claude", "settings.json");
const env = settings.env as Record<string, string>;
expect(env.KEEP_ME).toBe("1");
expect(settings.other).toBe(true);
expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
expect(env.ANTHROPIC_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max");
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("qwen3-max");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3-max");
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
});
test("qwen-code compatible-mode 走 openai 协议", () => {
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
const settings = readJsonAt(".qwen", "settings.json");
const security = settings.security as { auth: Record<string, string> };
expect(security.auth.selectedType).toBe("openai");
expect(security.auth.apiKey).toBe("sk-q");
expect(security.auth.baseUrl).toBe(OAI_URL);
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
expect(providers.openai[0]).toMatchObject({
id: "qwen3-coder-plus",
name: "bailian-cli",
baseUrl: OAI_URL,
envKey: "BAILIAN_CLI_API_KEY",
});
});
test("qwen-code anthropic 端点走 anthropic 协议", () => {
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
const settings = readJsonAt(".qwen", "settings.json");
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
"anthropic",
);
const providers = settings.modelProviders as Record<string, unknown>;
expect(Array.isArray(providers.anthropic)).toBe(true);
expect(providers.openai).toBeUndefined();
});
test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => {
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
const settings = readJsonAt(".qwen", "settings.json");
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
expect(openaiEntries).toHaveLength(1);
});
test("opencode 按端点选 npm含 setCacheKey合并保留其它 provider", () => {
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
writeFileSync(
join(home, ".config", "opencode", "opencode.json"),
JSON.stringify({ provider: { other: { name: "Other" } } }),
);
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
const config = readJsonAt(".config", "opencode", "opencode.json");
const provider = config.provider as Record<string, Record<string, unknown>>;
expect(provider.other).toBeDefined();
expect(provider["bailian-cli"].npm).toBe("@ai-sdk/anthropic");
const options = provider["bailian-cli"].options as Record<string, unknown>;
expect(options.baseURL).toBe(ANTHROPIC_URL);
expect(options.apiKey).toBe("sk-o");
expect(options.setCacheKey).toBe(true);
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
// 非 anthropic 端点用 openai-compatible
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
expect(
(
readJsonAt(".config", "opencode", "opencode.json").provider as Record<
string,
{ npm: string }
>
)["bailian-cli"].npm,
).toBe("@ai-sdk/openai-compatible");
});
test("openclaw 写入 provider、api 与 primary", () => {
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
const config = readJsonAt(".openclaw", "openclaw.json");
const models = config.models as Record<string, unknown>;
expect(models.mode).toBe("merge");
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
expect(bailian.api).toBe("openai-completions");
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
const agents = config.agents as { defaults: { model: { primary: string } } };
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
// anthropic 端点用 anthropic-messages
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
const config2 = readJsonAt(".openclaw", "openclaw.json");
expect(
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
"bailian-cli"
].api,
).toBe("anthropic-messages");
});
test("hermes 写入 custom_providers 与 model合并保留其它 provider", () => {
mkdirSync(join(home, ".hermes"), { recursive: true });
writeFileSync(
join(home, ".hermes", "config.yaml"),
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
);
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
expect(names).toContain("other");
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
(provider) => provider.name === "bailian-cli",
)!;
expect(entry.base_url).toBe(OAI_URL);
expect(entry.api_key).toBe("sk-h");
expect(entry.api_mode).toBe("chat_completions");
// anthropic 端点用 anthropic_messages
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
(provider) => provider.name === "bailian-cli",
)!;
expect(entry2.api_mode).toBe("anthropic_messages");
// upsertbailian-cli 项不重复
expect(
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
).toHaveLength(1);
});
test("codex 写入 config.toml 与 auth.jsoncc-switch 对齐结构,合并保留)", () => {
// 预置 config.toml 无关顶层键与另一个 provider验证非破坏性合并
mkdirSync(join(home, ".codex"), { recursive: true });
writeFileSync(
join(home, ".codex", "config.toml"),
[
'approval_policy = "on-request"',
"",
"[model_providers.other]",
'name = "other"',
'base_url = "https://other.example.com/v1"',
"",
].join("\n"),
);
// 预置 auth.json 无关键,验证合并保留
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain('model = "qwen3-coder-plus"');
expect(toml).toContain('model_reasoning_effort = "high"');
expect(toml).toContain("disable_response_storage = true");
expect(toml).toContain("[model_providers.bailian-cli]");
expect(toml).toContain(`base_url = "${OAI_URL}"`);
expect(toml).toContain('wire_api = "responses"');
expect(toml).toContain("requires_openai_auth = true");
// 合并:保留用户已有的无关配置
expect(toml).toContain('approval_policy = "on-request"');
expect(toml).toContain("[model_providers.other]");
const auth = readJsonAt(".codex", "auth.json");
expect(auth.OPENAI_API_KEY).toBe("sk-x");
expect(auth.EXISTING).toBe("keep");
});
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
mkdirSync(join(home, ".openclaw"), { recursive: true });
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
name.startsWith("openclaw.json.bak."),
);
expect(backups).toHaveLength(1);
});
});
+237
View File
@@ -0,0 +1,237 @@
import http from "node:http";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vite-plus/test";
import {
activateConfigProfile,
getConfigPath,
makeConfigStore,
writeConfigFile,
readConfigFile,
readConfigProfiles,
} from "bailian-cli-core";
import { createConfigUiServer } from "../src/commands/config/ui.ts";
const TOKEN = "test-token";
interface HttpResult {
status: number;
json: any;
text: string;
}
function httpJson(
port: number,
method: string,
path: string,
opts?: { body?: unknown; headers?: Record<string, string> },
): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined;
const headers: Record<string, string> = { ...opts?.headers };
if (payload) headers["Content-Type"] = "application/json";
const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => {
let d = "";
res.on("data", (c) => (d += c));
res.on("end", () => {
let json: unknown = null;
try {
json = d ? JSON.parse(d) : null;
} catch {
json = null;
}
resolve({ status: res.statusCode ?? 0, json, text: d });
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
/** 隔离临时配置目录 + 启动 UI server跑完清理。 */
async function withServer(fn: (port: number) => Promise<void>): Promise<void> {
const saved = process.env.BAILIAN_CONFIG_DIR;
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
process.env.BAILIAN_CONFIG_DIR = dir;
const server = createConfigUiServer(TOKEN, makeConfigStore());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
try {
await fn(port);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = saved;
rmSync(dir, { recursive: true, force: true });
}
}
test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", async () => {
await withServer(async (port) => {
await writeConfigFile({ api_key: "sk-default", output: "json" });
await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev");
await activateConfigProfile("dev");
const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
expect(res.status).toBe(200);
expect(res.json.activeProfile).toBe("dev");
expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" });
expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" });
expect(res.json.secretKeys).toContain("api_key");
expect(res.json.keys).toContain("default_image_to_video_model");
expect(res.json.keys).toContain("default_reference_to_video_model");
});
});
test("鉴权:错误 token 401、非 loopback Host 403", async () => {
await withServer(async (port) => {
const bad = await httpJson(port, "GET", `/api/config?token=wrong`);
expect(bad.status).toBe(401);
const badHost = await httpJson(port, "GET", `/api/config?token=${TOKEN}`, {
headers: { Host: "evil.com" },
});
expect(badHost.status).toBe(403);
});
});
test("POST /api/profile 写命名 profiletimeout 强制为 number空串清除键", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: {
name: "stage",
data: {
api_key: "sk-stage",
timeout: "90",
base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment",
},
},
});
expect(save.status).toBe(200);
expect(readConfigFile("stage")).toMatchObject({
api_key: "sk-stage",
timeout: 90,
base_url: "https://proxy.example.com",
});
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com");
// 空串清除 api_key整块替换
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "", timeout: "120" } },
});
expect(clear.status).toBe(200);
const after = readConfigFile("stage");
expect(after.api_key).toBeUndefined();
expect(after.timeout).toBe(120);
});
});
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
await withServer(async (port) => {
await writeConfigFile(
{
api_key: "sk-old",
output: "json",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
},
"stage",
);
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "sk-new" } },
});
expect(save.status).toBe(200);
const profile = readConfigFile("stage");
expect(profile).toMatchObject({
api_key: "sk-new",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
});
expect(profile.output).toBeUndefined();
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
expect(rawConfig.stage).toMatchObject({
api_key: "sk-new",
console_site: "international",
console_region: "ap-southeast-1",
console_switch_agent: 42,
telemetry: false,
});
expect(rawConfig.stage.output).toBeUndefined();
});
});
test("New profile 立即保存空 Profile其他配置读取可以看到", async () => {
await withServer(async (port) => {
const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "new-profile", data: {} },
});
expect(create.status).toBe(200);
expect(create.json.saved).toEqual({});
expect(readConfigProfiles().named["new-profile"]).toEqual({});
const list = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
expect(list.status).toBe(200);
expect(list.json.named["new-profile"]).toEqual({});
});
});
test("POST /api/profile 非法 key 返回 400", async () => {
await withServer(async (port) => {
const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { not_a_key: "x" } },
});
expect(res.status).toBe(400);
expect(String(res.json.error)).toMatch(/Invalid config key/);
});
});
test("DELETE /api/profile 删命名 profile缺 name 返回 400", async () => {
await withServer(async (port) => {
await writeConfigFile({ api_key: "sk-stage" }, "stage");
const del = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
expect(del.status).toBe(200);
expect(del.json.deleted).toBe(true);
expect(readConfigProfiles().named.stage).toBeUndefined();
const noName = await httpJson(port, "DELETE", `/api/profile?token=${TOKEN}`);
expect(noName.status).toBe(400);
});
});
test("Save & Activate 创建并激活 Profile删除激活项后切回 default", async () => {
await withServer(async (port) => {
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
body: { name: "stage", data: { api_key: "sk-stage" } },
});
expect(save.status).toBe(200);
const activate = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
body: { name: "stage" },
});
expect(activate.status).toBe(200);
expect(activate.json.activeProfile).toBe("stage");
expect(readConfigProfiles().active).toBe("stage");
const missing = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
body: { name: "missing" },
});
expect(missing.status).toBe(400);
expect(readConfigProfiles().active).toBe("stage");
const deleted = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
expect(deleted.status).toBe(200);
expect(deleted.json.activeProfile).toBe("default");
expect(readConfigProfiles().active).toBe("default");
});
});
+415 -52
View File
@@ -1,17 +1,62 @@
import { readFileSync } from "fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import {
isDashScopeE2EReady,
isOpenApiE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandE2e,
} from "./helpers.ts";
import { AUTH_ROUTES } from "./topic-routes.ts";
/**
* Auth 相关 E2E只验证 CLI 进程能正常解析参数并退出。
*/
interface ValidationServer {
baseUrl: string;
requests: Array<{
path: string;
body: Record<string, unknown>;
authorization?: string;
sourceConfig?: string;
}>;
close(): Promise<void>;
}
async function startValidationServer(statusCode = 200): Promise<ValidationServer> {
const requests: ValidationServer["requests"] = [];
const server = http.createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
requests.push({
path: request.url ?? "",
body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {},
authorization: request.headers.authorization,
sourceConfig: request.headers["x-dashscope-source-config"] as string | undefined,
});
response.writeHead(statusCode, { "Content-Type": "application/json" });
if (statusCode >= 400) {
response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" }));
return;
}
response.end(
JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
/** Auth E2E本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */
describe("e2e: auth", () => {
test("auth login --help 正常退出", async () => {
@@ -78,6 +123,35 @@ describe("e2e: auth", () => {
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
});
test("auth login --open-api --dry-run 使用 placeholder 时不请求服务端、不写配置", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-dry-run-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--open-api",
"--access-key-id",
"LTAI-e2e-placeholder",
"--access-key-secret",
"secret-e2e-placeholder",
"--dry-run",
],
{
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
},
);
expect(exitCode, stderr).toBe(0);
expect(stdout).toContain("Would save OpenAPI AK/SK credentials");
expect(existsSync(join(configDir, "config.json"))).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("auth logout --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--help"]);
expect(exitCode, stderr).toBe(0);
@@ -108,6 +182,247 @@ describe("e2e: auth", () => {
expect(stdout).toContain("Would validate and save API key.");
});
test("auth login --dry-run 仍校验显式 Base URL", async () => {
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
"login",
"--dry-run",
"--api-key",
"sk-e2e-dry-run-placeholder",
"--base-url",
"ftp://example.com/models",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid model base URL/);
});
test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-api-key-login");
const sdkBaseUrl = `${validationServer.baseUrl}/compatible-mode/v1/?source=login#fragment`;
try {
const login = await runCommandE2e(
AUTH_ROUTES,
["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(login.exitCode, login.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
expect(validationServer.requests[0]).toMatchObject({
path: "/compatible-mode/v1/chat/completions",
authorization: "Bearer sk-e2e-placeholder",
sourceConfig: expect.any(String),
body: {
model: "qwen3.7-max",
stream: false,
enable_thinking: false,
},
});
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.api_key).toBe("sk-e2e-placeholder");
expect(config.base_url).toBe(validationServer.baseUrl);
} finally {
await validationServer.close();
}
});
test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url");
try {
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--config",
"token-plan",
"--api-key",
"sk-sp-e2e-placeholder",
"--base-url",
`${validationServer.baseUrl}/apps/anthropic?source=sdk#fragment`,
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(login.exitCode, login.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
expect(validationServer.requests[0].path).toBe("/compatible-mode/v1/chat/completions");
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config["token-plan"]).toMatchObject({
api_key: "sk-sp-e2e-placeholder",
base_url: validationServer.baseUrl,
default_text_model: "qwen3.8-max-preview",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "happyhorse-1.1-i2v",
default_reference_to_video_model: "happyhorse-1.1-r2v",
default_image_model: "wan2.7-image",
});
} finally {
await validationServer.close();
}
});
test("auth login --config token-plan 物化并重置内置预设", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-token-plan-preset-login");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
"token-plan": {
default_text_model: "custom-text-model",
default_video_model: "custom-video-model",
default_image_to_video_model: "custom-image-to-video-model",
default_reference_to_video_model: "custom-reference-to-video-model",
default_image_model: "custom-image-model",
},
},
null,
2,
) + "\n",
);
try {
const login = await runCommandE2e(
AUTH_ROUTES,
["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted",
DASHSCOPE_BASE_URL: validationServer.baseUrl,
},
);
expect(login.exitCode, login.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
expect(validationServer.requests[0]).toMatchObject({
path: "/compatible-mode/v1/chat/completions",
authorization: "Bearer sk-sp-e2e-placeholder",
sourceConfig: expect.any(String),
body: {
model: "qwen3.8-max-preview",
stream: false,
enable_thinking: true,
},
});
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.api_key).toBeUndefined();
expect(config.active_config).toBe("token-plan");
expect(config["token-plan"]).toMatchObject({
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_text_model: "qwen3.8-max-preview",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "happyhorse-1.1-i2v",
default_reference_to_video_model: "happyhorse-1.1-r2v",
default_image_model: "wan2.7-image",
});
expect((config["token-plan"] as Record<string, unknown>).base_url).not.toBe(
validationServer.baseUrl,
);
expect((config["token-plan"] as Record<string, unknown>).api_key).not.toBe(
"sk-env-must-not-be-persisted",
);
} finally {
await validationServer.close();
}
});
test("auth login 未传 --config 时写当前激活 Config", async () => {
const validationServer = await startValidationServer();
const configDir = makeE2eOutputDir("auth-active-profile-login");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
active_config: "dev",
dev: { base_url: validationServer.baseUrl },
},
null,
2,
) + "\n",
);
const env = {
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
};
try {
const activeLogin = await runCommandE2e(
AUTH_ROUTES,
["auth", "login", "--api-key", "sk-active-placeholder"],
env,
);
expect(activeLogin.exitCode, activeLogin.stderr).toBe(0);
expect(validationServer.requests).toHaveLength(1);
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.api_key).toBeUndefined();
expect(config.active_config).toBe("dev");
expect(config.dev).toMatchObject({
api_key: "sk-active-placeholder",
base_url: validationServer.baseUrl,
});
} finally {
await validationServer.close();
}
});
test("auth login --api-key 验证失败不留下半配置", async () => {
const validationServer = await startValidationServer(400);
const configDir = makeE2eOutputDir("auth-api-key-login-failure");
try {
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--config",
"failed-profile",
"--api-key",
"sk-invalid",
"--base-url",
validationServer.baseUrl,
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(login.exitCode).not.toBe(0);
expect(login.stderr).toMatch(/invalid key/);
expect(login.stderr).not.toMatch(/API key validation failed|Invalid API key/);
expect(existsSync(join(configDir, "config.json"))).toBe(false);
} finally {
await validationServer.close();
}
});
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
@@ -172,6 +487,38 @@ describe("e2e: auth", () => {
expect(stderr).not.toContain("Cleared api_key");
});
test("auth logout 清除当前 Config 的全部凭证和 Base URL保留普通配置", async () => {
const configDir = makeE2eOutputDir("auth-logout-all");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
api_key: "sk-e2e-placeholder",
base_url: "https://model.example.com",
access_token: "console-token-placeholder",
access_key_id: "LTAI-e2e-placeholder",
access_key_secret: "secret-e2e-placeholder",
security_token: "sts-e2e-placeholder",
output: "json",
},
null,
2,
) + "\n",
);
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout"], {
BAILIAN_CONFIG_DIR: configDir,
});
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("api_key / base_url / access_token");
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config).toEqual({ output: "json" });
});
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
"auth",
@@ -251,57 +598,73 @@ describe("e2e: auth", () => {
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
});
test("auth login --open-api 持久化 OpenAPI AK/SK 并支持单独 logout", async () => {
const configDir = makeE2eOutputDir("auth-openapi-login");
const env = {
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
};
test.skipIf(!isOpenApiE2EReady())(
"auth login --open-api 使用环境中的真实 AK/SK持久化后支持单独 logout",
async () => {
const accessKeyId = process.env.ALIBABA_CLOUD_ACCESS_KEY_ID!.trim();
const accessKeySecret = process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET!.trim();
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-login-"));
const env = {
BAILIAN_CONFIG_DIR: configDir,
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
};
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--open-api",
"--access-key-id",
"LTAI-e2e-login-placeholder",
"--access-key-secret",
"secret-e2e-login-placeholder",
],
env,
);
expect(login.exitCode, login.stderr).toBe(0);
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
try {
const login = await runCommandE2e(
AUTH_ROUTES,
[
"auth",
"login",
"--open-api",
"--access-key-id",
accessKeyId,
"--access-key-secret",
accessKeySecret,
],
env,
);
expect(login.exitCode, login.stderr).toBe(0);
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(config.access_key_id).toBe("LTAI-e2e-login-placeholder");
expect(config.access_key_secret).toBe("secret-e2e-login-placeholder");
expect(config.openapi_access_key_id).toBeUndefined();
expect(config.openapi_access_key_secret).toBeUndefined();
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
unknown
>;
// 只断言布尔结果,避免失败 diff 把真实凭证打印到测试日志。
expect(config.access_key_id === accessKeyId).toBe(true);
expect(config.access_key_secret === accessKeySecret).toBe(true);
expect(config.openapi_access_key_id).toBeUndefined();
expect(config.openapi_access_key_secret).toBeUndefined();
const status = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
expect(status.exitCode, status.stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
}>(status.stdout);
expect(data.authenticated).toBe(true);
expect(data.openapi?.source).toBe("config");
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder");
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder");
const status = await runCommandE2e(
AUTH_ROUTES,
["auth", "status", "--output", "json"],
env,
);
expect(status.exitCode, status.stderr).toBe(0);
const data = parseStdoutJson<{
authenticated?: boolean;
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
}>(status.stdout);
expect(data.authenticated).toBe(true);
expect(data.openapi?.source).toBe("config");
expect(data.openapi?.access_key_id === accessKeyId).toBe(false);
expect(data.openapi?.access_key_secret === accessKeySecret).toBe(false);
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
expect(logout.exitCode, logout.stderr).toBe(0);
expect(logout.stderr).toMatch(/Cleared access_key_id/);
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
expect(logout.exitCode, logout.stderr).toBe(0);
expect(logout.stderr).toMatch(/Cleared access_key_id/);
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
expect(after.exitCode, after.stderr).toBe(0);
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout);
expect(afterData.openapi).toBeUndefined();
});
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
expect(after.exitCode, after.stderr).toBe(0);
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(
after.stdout,
);
expect(afterData.openapi).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
},
);
});
@@ -1,3 +1,6 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { CONFIG_ROUTES } from "./topic-routes.ts";
@@ -19,6 +22,36 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/set|--key|--value/i);
});
test("config list/use --help 正常退出", async () => {
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
expect(listResult.exitCode, listResult.stderr).toBe(0);
expect(listResult.stderr).toMatch(/list|active|profile/i);
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
expect(useResult.exitCode, useResult.stderr).toBe(0);
expect(useResult.stderr).toMatch(/use|--name|active/i);
});
test("config ui --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
});
test("config ui --dry-run 打印计划不起服务", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"ui",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ host?: string; routes?: string[] }>(stdout);
expect(data.host).toBe("127.0.0.1");
expect(Array.isArray(data.routes)).toBe(true);
});
test("config show --output json", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
@@ -48,12 +81,114 @@ describe("e2e: config", () => {
expect(stdout).toMatch(/config_file|timeout|base_url/i);
});
test("config show 脱敏 security_token", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-secret-"));
try {
const securityToken = "sts-sensitive-token";
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ security_token: securityToken }, null, 2) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
["config", "show", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ security_token?: string }>(stdout);
expect(data.security_token).toBe("sts-...oken");
expect(stdout).not.toContain(securityToken);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--key|--value|Usage:/i);
});
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/--name|Usage:/i);
});
test("config use 持久化激活项config list 展示激活状态", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
const env = { BAILIAN_CONFIG_DIR: configDir };
const useResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "dev", "--output", "json"],
env,
);
expect(useResult.exitCode, useResult.stderr).toBe(0);
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
"dev",
);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
const listResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "list", "--output", "json"],
env,
);
expect(listResult.exitCode, listResult.stderr).toBe(0);
const listData = parseStdoutJson<{
active_config?: string;
profiles?: string[];
}>(listResult.stdout);
expect(listData.active_config).toBe("dev");
expect(listData.profiles).toEqual(["default", "dev"]);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config use --dry-run 校验目标但不修改激活项", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
const result = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
"dev",
);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config use 拒绝不存在的 Profile 且不写入状态", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
try {
const configPath = join(configDir, "config.json");
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
const result = await runCommandE2e(
CONFIG_ROUTES,
["config", "use", "--name", "missing", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(result.exitCode).toBe(2);
expect(result.stderr).toMatch(/does not exist/);
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config set 非法 key 时退出为用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
@@ -93,6 +228,43 @@ describe("e2e: config", () => {
expect(stderr).toMatch(/Invalid timeout|positive/i);
});
test("config set 归一化 Base URL 并拒绝非法协议", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-base-url-"));
try {
const setResult = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"set",
"--key",
"base_url",
"--value",
"https://proxy.example.com/bailian/compatible-mode/v1/?x=1#fragment",
"--output",
"json",
],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
"https://proxy.example.com",
);
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
"https://proxy.example.com",
);
const invalidResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(invalidResult.exitCode).toBe(2);
expect(invalidResult.stderr).toMatch(/Invalid model base URL/);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config set --dry-run 不落盘(仅输出 would_set", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
@@ -127,6 +299,61 @@ describe("e2e: config", () => {
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
});
test("config set --dry-run 支持图生视频默认模型别名", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"default-image-to-video-model",
"--value",
"happyhorse-1.1-i2v",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_set?: { default_image_to_video_model?: string };
}>(stdout);
expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v");
});
test("config set --dry-run 支持参考生视频默认模型别名", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"default-reference-to-video-model",
"--value",
"happyhorse-1.1-r2v",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_set?: { default_reference_to_video_model?: string };
}>(stdout);
expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v");
});
test("config set --dry-run 展示归一化后的 Base URL", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"set",
"--dry-run",
"--key",
"base-url",
"--value",
"https://proxy.example.com/apps/anthropic/?x=1#fragment",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ would_set?: { base_url?: string } }>(stdout);
expect(data.would_set?.base_url).toBe("https://proxy.example.com");
});
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
@@ -156,4 +383,141 @@ describe("e2e: config", () => {
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/);
});
test("config agent --help 正常退出", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/agent|--base-url|--model/i);
});
test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--api-key|Usage:/i);
});
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
"config",
"agent",
"--agent",
"not-an-agent",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--api-key",
"sk-placeholder",
"--model",
"qwen3-max",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not-an-agent|claude-code|agent/i);
});
test("config agent --dry-run 输出脱敏信息且不写盘", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-dry-"));
try {
const { stdout, stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"claude-code",
"--base-url",
"https://dashscope.aliyuncs.com/apps/anthropic",
"--api-key",
"sk-secret-placeholder",
"--model",
"qwen3-max",
"--dry-run",
"--output",
"json",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
agent?: string;
base_url?: string;
model?: string;
api_key?: string;
}>(stdout);
expect(data.agent).toBe("claude-code");
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
expect(data.model).toBe("qwen3-max");
expect(stdout).not.toContain("sk-secret-placeholder");
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("config agent codex 写入 config.toml 与 auth.jsoncc-switch 对齐结构)", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-"));
try {
const { stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"codex",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--api-key",
"sk-codex-placeholder",
"--model",
"qwen3-coder-plus",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
expect(toml).toContain('model_provider = "bailian-cli"');
expect(toml).toContain("requires_openai_auth = true");
expect(toml).toContain('wire_api = "responses"');
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("config agent hermes 写入 custom_providers 结构", async () => {
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
try {
const { stderr, exitCode } = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"agent",
"--agent",
"hermes",
"--base-url",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"--api-key",
"sk-hermes-placeholder",
"--model",
"qwen3-coder-plus",
],
{ HOME: home },
);
expect(exitCode, stderr).toBe(0);
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
expect(yamlText).toContain("custom_providers");
expect(yamlText).toContain("bailian-cli");
expect(yamlText).toContain("api_mode: chat_completions");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
+1
View File
@@ -28,6 +28,7 @@ export {
isChatE2EReady,
isConsoleE2EReady,
isDashScopeE2EReady,
isOpenApiE2EReady,
isSearchE2EReady,
} from "e2e/gating";
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { join } from "path";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
e2eFixturesDir,
e2eLabelFromMetaUrl,
@@ -46,6 +47,55 @@ describe("e2e: image edit", () => {
expect(data.mode).toBe("async");
expect(data.request?.input?.messages?.length).toBeGreaterThan(0);
});
test("Token Plan 使用 Base64 传入 wan2.7-image 本地图片", async () => {
const configDir = makeE2eOutputDir("image-edit-token-plan-local-image");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_image_model: "wan2.7-image",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
IMAGE_ROUTES,
[
"image",
"edit",
"--config",
"token-plan",
"--image",
join(e2eFixturesDir, ".smoke-32.png"),
"--prompt",
"改成蓝色",
"--dry-run",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
mode?: string;
request?: {
model?: string;
input?: { messages?: Array<{ content?: Array<{ image?: string }> }> };
};
}>(stdout);
expect(data.mode).toBe("sync");
expect(data.request?.model).toBe("wan2.7-image");
expect(data.request?.input?.messages?.[0]?.content?.[0]?.image).toBe(
"data:image/png;base64,<omitted>",
);
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => {
@@ -1,4 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
@@ -21,6 +23,44 @@ describe("e2e: image generate", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/generate|--prompt|--model/i);
});
test("Token Plan 默认使用 wan2.7-image 同步接口", async () => {
const configDir = makeE2eOutputDir("image-generate-token-plan-default");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_image_model: "wan2.7-image",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
IMAGE_ROUTES,
[
"image",
"generate",
"--config",
"token-plan",
"--prompt",
"一只猫",
"--dry-run",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ mode?: string; request?: { model?: string } }>(stdout);
expect(data.mode).toBe("sync");
expect(data.request?.model).toBe("wan2.7-image");
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
@@ -15,6 +15,10 @@ export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
export const CONFIG_ROUTES: E2eRouteExports = {
"config show": "configShow",
"config set": "configSet",
"config list": "configList",
"config use": "configUse",
"config ui": "configUi",
"config agent": "configAgent",
};
export const MEMORY_ROUTES: E2eRouteExports = {
@@ -55,6 +59,8 @@ export const VIDEO_ROUTES: E2eRouteExports = {
"video download": "videoDownload",
};
export const VISION_ROUTES: E2eRouteExports = { "vision describe": "visionDescribe" };
export const SPEECH_ROUTES: E2eRouteExports = {
"speech synthesize": "speechSynthesize",
"speech recognize": "speechRecognize",
@@ -6,12 +6,12 @@ import {
runCommandE2e,
} from "./helpers.ts";
import { USAGE_ROUTES } from "./topic-routes.ts";
import { readConfigFile } from "bailian-cli-core";
import { buildSources } from "bailian-cli-core";
function getStaticWorkspaceId(): string | undefined {
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
try {
const config = readConfigFile();
const config = buildSources({}).file;
if (config.workspace_id) return config.workspace_id;
} catch {}
return undefined;
@@ -1,3 +1,4 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -21,6 +22,96 @@ describe("e2e: video generate (i2v)", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/generate|--prompt|--image|model/i);
});
test("Token Plan 使用独立的图生视频默认模型", async () => {
const configDir = makeE2eOutputDir("video-i2v-token-plan-default");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_video_model: "happyhorse-1.1-t2v",
default_image_to_video_model: "custom-image-to-video-model",
},
},
null,
2,
) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VIDEO_ROUTES,
[
"video",
"generate",
"--config",
"token-plan",
"--dry-run",
"--image",
"https://example.com/placeholder.png",
"--prompt",
"干跑校验",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { model?: string; input?: { media?: Array<{ type?: string }> } };
}>(stdout);
expect(data.request?.model).toBe("custom-image-to-video-model");
expect(data.request?.input?.media?.[0]?.type).toBe("first_frame");
});
test("Token Plan 图生视频将本地首帧转换为 Base64", async () => {
const configDir = makeE2eOutputDir("video-i2v-token-plan-local-image");
const imagePath = join(configDir, "first-frame.png");
writeFileSync(imagePath, Buffer.from([1, 2, 3]));
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_image_to_video_model: "happyhorse-1.1-i2v",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VIDEO_ROUTES,
[
"video",
"generate",
"--config",
"token-plan",
"--dry-run",
"--image",
imagePath,
"--prompt",
"让画面动起来",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { input?: { media?: Array<{ url?: string }> } };
}>(stdout);
expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,<omitted>");
});
});
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
@@ -1,3 +1,4 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -43,6 +44,92 @@ describe("e2e: video ref (r2v)", () => {
);
expect(data.request?.input?.media?.[0]?.url).toBe("https://example.com/person.png");
});
test("Token Plan 使用独立的参考生视频默认模型", async () => {
const configDir = makeE2eOutputDir("video-r2v-token-plan-default");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify(
{
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_reference_to_video_model: "custom-reference-to-video-model",
},
},
null,
2,
) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VIDEO_ROUTES,
[
"video",
"ref",
"--config",
"token-plan",
"--dry-run",
"--prompt",
"Image 1 waves",
"--image",
"https://example.com/person.png",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { model?: string } }>(stdout);
expect(data.request?.model).toBe("custom-reference-to-video-model");
});
test("Token Plan 参考生视频将本地参考图转换为 Base64", async () => {
const configDir = makeE2eOutputDir("video-r2v-token-plan-local-image");
const imagePath = join(configDir, "reference.png");
writeFileSync(imagePath, Buffer.from([1, 2, 3]));
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_reference_to_video_model: "happyhorse-1.1-r2v",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VIDEO_ROUTES,
[
"video",
"ref",
"--config",
"token-plan",
"--dry-run",
"--image",
imagePath,
"--prompt",
"Image 1 waves",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { input?: { media?: Array<{ url?: string }> } };
}>(stdout);
expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,<omitted>");
});
});
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
@@ -0,0 +1,45 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { makeE2eOutputDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { VISION_ROUTES } from "./topic-routes.ts";
describe("e2e: vision describe", () => {
test("Token Plan 默认使用支持视觉理解的文本模型", async () => {
const configDir = makeE2eOutputDir("vision-describe-token-plan-default");
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({
"token-plan": {
api_key: "sk-sp-e2e-placeholder",
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
default_text_model: "qwen3.8-max-preview",
},
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
VISION_ROUTES,
[
"vision",
"describe",
"--config",
"token-plan",
"--image",
"https://example.com/image.png",
"--dry-run",
"--output",
"json",
],
{
BAILIAN_CONFIG_DIR: configDir,
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
},
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { model?: string } }>(stdout);
expect(data.request?.model).toBe("qwen3.8-max-preview");
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.8.2",
"version": "1.10.1",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -51,6 +51,6 @@
"vite-plus": "catalog:"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
}
}
+1
View File
@@ -13,3 +13,4 @@ export type {
AuthState,
CredentialSource,
} from "./types.ts";
export { generateCLIAccessToken, refreshAccessToken } from "./refresh-token.ts";
+91
View File
@@ -0,0 +1,91 @@
import { REGIONS, type Region } from "../config/schema.ts";
import type { Identity, Settings } from "../config/schema.ts";
import { readConfigFile, writeConfigFile } from "../config/loader.ts";
import { Client } from "../client/client.ts";
const API_VERSION = "2026-02-10";
const API_ACTION = "GenerateCLIAccessToken";
const API_PATH = "/modelstudio/cli/generateAccessToken";
const MODEL_STUDIO_HOSTS: Partial<Record<Region, string>> = {
cn: "modelstudio.cn-beijing.aliyuncs.com",
intl: "modelstudio.ap-southeast-1.aliyuncs.com",
};
function resolveRegion(baseUrl: string): Region {
for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) {
if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region;
}
return "cn";
}
function modelStudioHost(baseUrl: string): string {
const region = resolveRegion(baseUrl);
return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!;
}
export async function generateCLIAccessToken(opts: {
identity: Identity;
settings: Settings;
baseUrl: string;
accessKeyId: string;
accessKeySecret: string;
securityToken?: string;
}): Promise<any> {
const { identity, settings, baseUrl, accessKeyId, accessKeySecret, securityToken } = opts;
const client = new Client({
identity,
settings,
baseUrl,
openApiCred: { accessKeyId, accessKeySecret, securityToken, source: "flag" },
});
const host = modelStudioHost(baseUrl);
return client.openApiQueryJson({
host,
path: API_PATH,
action: API_ACTION,
version: API_VERSION,
method: "POST",
queryParams: {},
});
}
/**
* Try to refresh the console access_token using stored AK/SK.
* Returns the new token on success, or null if AK/SK are not available.
*/
export async function refreshAccessToken(opts: {
identity: Identity;
settings: Settings;
baseUrl: string;
}): Promise<string | null> {
const configName = opts.settings.configName;
const config = readConfigFile(configName);
const accessKeyId = config.access_key_id;
const accessKeySecret = config.access_key_secret;
if (!accessKeyId || !accessKeySecret) return null;
if (opts.settings.verbose) {
process.stderr.write("Refreshing access token...\n");
}
const resp = await generateCLIAccessToken({
identity: opts.identity,
settings: opts.settings,
baseUrl: opts.baseUrl,
accessKeyId,
accessKeySecret,
});
const token: string | undefined = resp.cliAccessToken;
if (!token) return null;
const existing = readConfigFile(configName) as Record<string, unknown>;
existing.access_token = token;
await writeConfigFile(existing, configName);
return token;
}
+12 -4
View File
@@ -1,4 +1,5 @@
import { REGIONS } from "../config/schema.ts";
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
import type { ResolutionSources } from "../config/loader.ts";
import type { ApiKeyCredential, ConsoleCredential, OpenApiCredential, AuthState } from "./types.ts";
import { BailianError } from "../errors/base.ts";
@@ -7,9 +8,11 @@ import { ExitCode } from "../errors/codes.ts";
// Resolve the credential for a command's declared domain (model = api-key,
// console = access-token), by priority, or throw. Read only from sources.
/** Model-domain baseUrl(flag > env > file > cn)——无需 key 也可解析;login 验证等用。 */
export function resolveModelBaseUrl(s: ResolutionSources): string {
return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || REGIONS.cn;
/** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */
export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string {
return normalizeModelBaseUrl(
s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback,
);
}
/**
@@ -55,6 +58,7 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential {
s.flags.accessKeyId,
s.flags.accessKeySecret,
s.flags.accessKeyId !== undefined || s.flags.accessKeySecret !== undefined,
s.flags.securityToken,
);
if (flagCred) return flagCred;
@@ -66,6 +70,7 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential {
trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_ID) ||
trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET),
),
s.env.ALIBABA_CLOUD_SECURITY_TOKEN,
);
if (envCred) return envCred;
@@ -74,6 +79,7 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential {
s.file.access_key_id,
s.file.access_key_secret,
Boolean(s.file.access_key_id || s.file.access_key_secret),
s.file.security_token,
);
if (configCred) return configCred;
@@ -89,6 +95,7 @@ function resolveOpenApiPair(
rawAccessKeyId: string | undefined,
rawAccessKeySecret: string | undefined,
provided: boolean,
rawSecurityToken?: string,
): OpenApiCredential | undefined {
if (!provided) return undefined;
@@ -103,7 +110,8 @@ function resolveOpenApiPair(
);
}
return { accessKeyId, accessKeySecret, source };
const securityToken = trimNonEmpty(rawSecurityToken);
return { accessKeyId, accessKeySecret, securityToken, source };
}
function trimNonEmpty(value: string | undefined): string | undefined {
+41 -16
View File
@@ -1,13 +1,22 @@
import type { ConfigFile } from "../config/schema.ts";
import type { ResolutionSources } from "../config/loader.ts";
import { readConfigFile, writeConfigFile } from "../config/loader.ts";
import { getConfigPath } from "../config/paths.ts";
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
import type { AuthState } from "./types.ts";
import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts";
const LOGOUT_KEYS = {
console: ["access_token"],
openapi: ["access_key_id", "access_key_secret"],
all: ["api_key", "access_token", "access_key_id", "access_key_secret"],
openapi: ["access_key_id", "access_key_secret", "security_token"],
all: [
"api_key",
"base_url",
"access_token",
"access_key_id",
"access_key_secret",
"security_token",
],
} as const;
/** 登录允许落盘的键:凭证本体 + 登录回调携带的连接/作用域字段。 */
@@ -17,11 +26,17 @@ export type AuthPersistPatch = Pick<
| "access_token"
| "access_key_id"
| "access_key_secret"
| "security_token"
| "base_url"
| "console_site"
| "console_region"
| "console_switch_agent"
| "workspace_id"
| "default_text_model"
| "default_video_model"
| "default_image_to_video_model"
| "default_reference_to_video_model"
| "default_image_model"
>;
/**
@@ -31,43 +46,53 @@ export type AuthPersistPatch = Pick<
export interface AuthStore {
/** 各域"将会解析出"的凭证快照(auth status 用)。 */
describe(): AuthState;
/** 磁盘上当前是否存有各域凭证(区别于 describe:只看 file,不含 flag/env 源)。 */
stored(): { apiKey: boolean; console: boolean; openapi: boolean };
/** model 域 baseUrl 链(flag > env > file > 默认);验证 API key 等无凭证场景用。 */
resolveBaseUrl(): string;
/** 登录落盘:合并写入,undefined 键忽略。 */
/** 磁盘上当前是否存有各域凭证及 model baseUrl(区别于 describe:只看 file,不含 flag/env 源)。 */
stored(): { apiKey: boolean; console: boolean; openapi: boolean; baseUrl?: string };
/** model 域 baseUrl 链(flag > env > config file > fallback)。 */
resolveBaseUrl(fallback?: string): string;
/** 登录落盘:合并写入,undefined 键忽略;显式 --config 成功后同时激活目标 Profile。 */
login(patch: AuthPersistPatch): Promise<void>;
/** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */
/** 清凭证:console/openapi 只删对应域;all 清全部登录凭证和 model baseUrl。返回是否有变更。 */
logout(scope: "console" | "openapi" | "all"): Promise<boolean>;
/** 实际写入的 config.json 路径(不受命名配置影响,一直是同一个文件)。 */
path: string;
}
export function makeAuthStore(sources: ResolutionSources): AuthStore {
const configName = sources.configName;
const activateAfterLogin = sources.flags.config !== undefined;
return {
describe: () => describeAuthState(sources),
stored() {
const file = readConfigFile();
const file = readConfigFile(configName);
return {
apiKey: !!file.api_key,
console: !!file.access_token,
openapi: !!(file.access_key_id || file.access_key_secret),
openapi: !!(file.access_key_id || file.access_key_secret || file.security_token),
baseUrl: file.base_url,
};
},
resolveBaseUrl: () => resolveModelBaseUrl(sources),
resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback),
async login(patch) {
const existing = readConfigFile() as Record<string, unknown>;
const existing = readConfigFile(configName) as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value !== undefined) existing[key] = value;
if (value !== undefined) {
existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value;
}
}
await writeConfigFile(existing);
await writeConfigFile(existing, configName, { activate: activateAfterLogin });
},
async logout(scope) {
const existing = readConfigFile() as Record<string, unknown>;
const existing = readConfigFile(configName) as Record<string, unknown>;
const keys = LOGOUT_KEYS[scope];
const had = keys.some((key) => existing[key] !== undefined);
if (!had) return false;
for (const key of keys) delete existing[key];
await writeConfigFile(existing);
await writeConfigFile(existing, configName);
return true;
},
get path() {
return sources.configPath ?? getConfigPath();
},
};
}
+1
View File
@@ -22,6 +22,7 @@ export interface ConsoleCredential {
export interface OpenApiCredential {
accessKeyId: string;
accessKeySecret: string;
securityToken?: string;
source: CredentialSource;
}
+7 -3
View File
@@ -1,10 +1,11 @@
import { createHmac, createHash, randomUUID } from "crypto";
export type AcsQueryParams = Record<string, string | string[] | undefined>;
export type AcsQueryParams = Record<string, string | string[] | undefined | number>;
export interface AcsSignConfig {
accessKeyId: string;
accessKeySecret: string;
securityToken?: string;
action: string;
version: string;
body: string;
@@ -17,7 +18,7 @@ export interface AcsSignConfig {
/** Build ACS3 canonical query string from OpenAPI query parameters. */
export function buildAcsCanonicalQuery(params: AcsQueryParams): string {
const pairs: Array<[string, string]> = [];
const pairs: Array<[string, string | number | undefined]> = [];
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === "") continue;
if (Array.isArray(value)) {
@@ -30,7 +31,9 @@ export function buildAcsCanonicalQuery(params: AcsQueryParams): string {
}
}
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
return pairs
.map(([key, value]) => `${encodeRFC3986(key)}=${encodeRFC3986(String(value))}`)
.join("&");
}
export function signAcsRequest(cfg: AcsSignConfig): Record<string, string> {
@@ -49,6 +52,7 @@ export function signAcsRequest(cfg: AcsSignConfig): Record<string, string> {
"x-acs-content-sha256": hashedBody,
"content-type": "application/json",
};
if (cfg.securityToken) headers["x-acs-security-token"] = cfg.securityToken;
const signedHeaderKeys = Object.keys(headers)
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
+116
View File
@@ -0,0 +1,116 @@
import type { Identity, Settings } from "../config/schema.ts";
import { Client, type OpenApiResponse } from "./client.ts";
const VERSION = "2024-08-16";
// BailianControl is a ROA-style product: each API has its own pathname
// (e.g. GetApiKey -> /bailianControl/apiKey/getApiKey), not the RPC `/`.
const CREATE_USER_ACTION = "CreateUser";
const CREATE_USER_PATH = "/bailianControl/User/createUser";
const LIST_WORKSPACES_ACTION = "ListWorkspaces";
const LIST_WORKSPACES_PATH = "/bailianControl/workspaces";
const RESET_POLICIES_ACTION = "ChangeUserPermissions";
const RESET_POLICIES_PATH = "/bailianControl/serviserAuthorityPolicy/resetPolicies4Agent";
function bailianControlHost(regionId: string): string {
return `bailiancontrol.${regionId}.aliyuncs.com`;
}
/** Shared inputs for every BailianControl OpenAPI call (AK/SK passed explicitly). */
export interface BailianControlAuth {
identity: Identity;
settings: Settings;
baseUrl: string;
regionId: string;
accessKeyId: string;
accessKeySecret: string;
securityToken?: string;
}
function bailianControlClient(auth: BailianControlAuth): Client {
return new Client({
identity: auth.identity,
settings: auth.settings,
baseUrl: auth.baseUrl,
openApiCred: {
accessKeyId: auth.accessKeyId,
accessKeySecret: auth.accessKeySecret,
securityToken: auth.securityToken,
source: "flag",
},
});
}
export interface CreateUserReqDTO {
outerKey: string;
nickName: string;
userName: string;
}
/**
* Create a Bailian console user via the BailianControl OpenAPI (`CreateUser`),
* signed with Alibaba Cloud AK/SK. Mirrors {@link generateCLIAccessToken}: the
* caller passes AK/SK explicitly, so this needs no stored credential.
*/
export async function createBailianControlUser(
opts: BailianControlAuth & { reqDTO: CreateUserReqDTO },
): Promise<OpenApiResponse> {
const client = bailianControlClient(opts);
// The CreateUser request carries a single `data` param whose value is the
// console payload re-encoded as a JSON string: {"data":"{\"reqDTO\":{...}}"}.
return client.openApiJson({
host: bailianControlHost(opts.regionId),
path: CREATE_USER_PATH,
action: CREATE_USER_ACTION,
version: VERSION,
method: "POST",
body: { data: JSON.stringify({ reqDTO: opts.reqDTO }) },
});
}
/** List workspaces (used to resolve the agent id for permission changes). */
export async function listBailianControlWorkspaces(
opts: BailianControlAuth,
): Promise<OpenApiResponse> {
const client = bailianControlClient(opts);
// GET carries the console payload as a `data` query param, re-encoded as a
// JSON string: ?data={"reqDTO":{}}.
return client.openApiJson({
host: bailianControlHost(opts.regionId),
path: LIST_WORKSPACES_PATH,
action: LIST_WORKSPACES_ACTION,
version: VERSION,
method: "GET",
queryParams: {
data: JSON.stringify({ reqDTO: {}, cornerstoneParam: {} }),
},
});
}
/**
* Authorize a user's servicer permissions via `ResetPolicies4Agent`. The single
* `data` param carries the console payload re-encoded as a JSON string.
*/
export async function resetBailianControlPolicies4Agent(
opts: BailianControlAuth & {
outerKey: string;
agentId: number;
policyIndexList?: number[];
},
): Promise<OpenApiResponse> {
const client = bailianControlClient(opts);
return client.openApiJson({
host: bailianControlHost(opts.regionId),
path: RESET_POLICIES_PATH,
action: RESET_POLICIES_ACTION,
version: VERSION,
method: "POST",
body: {
data: JSON.stringify({
cornerstoneParam: {},
outerKey: opts.outerKey,
policyIndexList: opts.policyIndexList ?? [1],
agentId: opts.agentId,
}),
},
});
}
+97 -11
View File
@@ -4,9 +4,10 @@ import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
import { isLocalFile, resolveFileUrl } from "../files/upload.ts";
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
import { McpClient } from "./mcp.ts";
import { callConsoleGateway } from "../console/gateway.ts";
import { refreshAccessToken } from "../auth/refresh-token.ts";
import { maskToken } from "../utils/token.ts";
import { trackingHeaders } from "./headers.ts";
@@ -35,6 +36,17 @@ export interface ClientOpenApiQueryOpts {
queryParams: AcsQueryParams;
}
export interface ClientOpenApiJsonOpts {
host: string;
path: string;
action: string;
version: string;
method: "GET" | "POST";
/** JSON request body; omit for query-only calls (signed as an empty body). */
body?: unknown;
queryParams?: AcsQueryParams;
}
export interface OpenApiResponse {
Success?: boolean;
Code?: string;
@@ -106,33 +118,90 @@ export class Client {
return resolveFileUrl(source, this.requireApi().token, model, opts);
}
/**
* Resolve an image input while keeping Token Plan's upload limitation isolated.
* Token Plan local images are sent as Data URIs; every other connection keeps
* the established temporary OSS upload flow. URLs and existing Data URIs pass through.
*/
resolveImageInput(
source: string,
model: string,
opts: { signal?: AbortSignal } = {},
): Promise<string> {
if (!isLocalFile(source)) return Promise.resolve(source);
if (this.usesTokenPlanEndpoint()) {
return Promise.resolve(imageFileToDataUri(source));
}
return this.uploadFile(source, model, { signal: opts.signal });
}
usesTokenPlanEndpoint(): boolean {
if (this.deps.settings.configName === "token-plan") return true;
try {
return /^token-plan\.[a-z0-9-]+\.maas\.aliyuncs\.com$/i.test(new URL(this.baseUrl).hostname);
} catch {
return false;
}
}
/** Open an MCP client. Accepts a path (prepended with the model baseUrl) or an absolute URL. */
mcp(pathOrUrl: string): McpClient {
const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : this.requireApi().baseUrl + pathOrUrl;
return new McpClient(this.http, url, this.deps.apiCred?.token);
}
console<T>(api: string, data: Record<string, unknown>): Promise<T> {
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
if (!this.deps.consoleCred) {
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);
}
// region / site / switchAgent 已解析在 consoleCred 里,gateway 不再回读 config。
return callConsoleGateway(this.deps.consoleCred, this.deps.settings.timeout, {
api,
data,
}) as Promise<T>;
const gwOpts = { api, data };
const { timeout } = this.deps.settings;
try {
return (await callConsoleGateway(
this.deps.consoleCred,
timeout,
gwOpts,
this.deps.settings,
)) as T;
} catch (err) {
if (
!(err instanceof BailianError) ||
err.exitCode !== ExitCode.AUTH ||
!err.message.includes("not logged in")
) {
throw err;
}
const newToken = await refreshAccessToken({
identity: this.deps.identity,
settings: this.deps.settings,
baseUrl: this.deps.baseUrl,
});
if (!newToken) throw err;
return (await callConsoleGateway(
{ ...this.deps.consoleCred, token: newToken },
timeout,
gwOpts,
this.deps.settings,
)) as T;
}
}
async openApiQueryJson<T extends OpenApiResponse>(opts: ClientOpenApiQueryOpts): Promise<T> {
openApiQueryJson<T extends OpenApiResponse>(opts: ClientOpenApiQueryOpts): Promise<T> {
return this.openApiJson<T>(opts);
}
async openApiJson<T extends OpenApiResponse>(opts: ClientOpenApiJsonOpts): Promise<T> {
const cred = this.requireOpenApi();
const queryString = buildAcsCanonicalQuery(opts.queryParams);
const bodyStr = opts.body === undefined ? "" : JSON.stringify(opts.body);
const queryString = opts.queryParams ? buildAcsCanonicalQuery(opts.queryParams) : "";
const endpoint = `https://${opts.host}${opts.path}${queryString ? `?${queryString}` : ""}`;
const headers = signAcsRequest({
accessKeyId: cred.accessKeyId,
accessKeySecret: cred.accessKeySecret,
securityToken: cred.securityToken,
action: opts.action,
version: opts.version,
body: "",
body: bodyStr,
host: opts.host,
pathname: opts.path,
method: opts.method,
@@ -141,21 +210,38 @@ export class Client {
if (this.deps.settings.verbose) {
process.stderr.write(`> ${opts.method} ${endpoint}\n`);
process.stderr.write(`> x-acs-action: ${opts.action} (version ${opts.version})\n`);
process.stderr.write(`> AK: ${maskToken(cred.accessKeyId)}\n`);
if (cred.securityToken) {
process.stderr.write(`> STS token: ${maskToken(cred.securityToken)}\n`);
}
if (queryString) process.stderr.write(`> query: ${queryString}\n`);
if (bodyStr) process.stderr.write(`> body: ${bodyStr}\n`);
}
const timeoutMs = this.deps.settings.timeout * 1000;
const res = await fetch(endpoint, {
method: opts.method,
headers: { ...headers, ...trackingHeaders() },
body: bodyStr || undefined,
signal: AbortSignal.timeout(timeoutMs),
});
const rawText = await res.text();
if (this.deps.settings.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
process.stderr.write(`< ${rawText}\n`);
}
const data = (await res.json()) as T;
let data: T;
try {
data = JSON.parse(rawText) as T;
} catch {
throw new BailianError(
`${res.status} ${res.statusText} - ${rawText.slice(0, 500)}`,
ExitCode.GENERAL,
);
}
if (!res.ok || data.Success === false) {
throw new BailianError(
`${data.Code || res.status} - ${data.Message || res.statusText}`,
+13 -1
View File
@@ -21,7 +21,19 @@ export {
export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts";
export type { RequestOpts } from "./http.ts";
export { request, requestJson } from "./http.ts";
export { Client, type ClientRequestOpts, type ClientOpenApiQueryOpts } from "./client.ts";
export {
Client,
type ClientRequestOpts,
type ClientOpenApiQueryOpts,
type ClientOpenApiJsonOpts,
} from "./client.ts";
export {
createBailianControlUser,
listBailianControlWorkspaces,
resetBailianControlPolicies4Agent,
type BailianControlAuth,
type CreateUserReqDTO,
} from "./bailian-control.ts";
export {
buildAcsCanonicalQuery,
signAcsRequest,
+11 -2
View File
@@ -1,6 +1,15 @@
export type { ConfigFile, Region, Identity, Settings } from "./schema.ts";
export { BAILIAN_HOST, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
export { readConfigFile, writeConfigFile } from "./loader.ts";
export { BAILIAN_HOST, CONFIG_FILE_KEYS, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
export { normalizeConfigName, readConfigFile, writeConfigFile } from "./loader.ts";
export {
activateConfigProfile,
validateConfigProfileActivation,
readConfigProfiles,
deleteConfigProfile,
type ConfigProfiles,
} from "./loader.ts";
export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts";
export { makeConfigStore, type ConfigStore } from "./store.ts";
export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts";
export { getModelProfilePreset } from "./profile-presets.ts";
export { normalizeModelBaseUrl } from "./model-base-url.ts";
+177 -7
View File
@@ -1,16 +1,50 @@
import { readFileSync, writeFileSync, renameSync, existsSync } from "fs";
import { parseConfigFile, type ConfigFile, type Settings } from "./schema.ts";
import { CONFIG_FILE_KEYS, parseConfigFile, type ConfigFile, type Settings } from "./schema.ts";
import { ensureConfigDir, getConfigPath } from "./paths.ts";
import { detectOutputFormat } from "../output/formatter.ts";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import type { SourceFlags } from "../types/command.ts";
export function readConfigFile(): ConfigFile {
const CONFIG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const ACTIVE_CONFIG_KEY = "active_config";
function isConfigBlock(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
/**
* 校验并规范化 `--config <name>``undefined`/""/"default" 都视为未指定(等价顶层默认配置)。
* 合法命名只允许字母、数字、`-`/`_`,且不能与 `ConfigFile` 顶层字段同名(避免写入时与默认配置字段歧义)。
*/
export function normalizeConfigName(name?: unknown): string | undefined {
if (name === undefined || name === "" || name === "default") return undefined;
if (typeof name !== "string" || !CONFIG_NAME_PATTERN.test(name)) {
const display = typeof name === "string" ? name : JSON.stringify(name);
throw new BailianError(
`Invalid config name "${display}".`,
ExitCode.USAGE,
"Use letters, numbers, '-' or '_', starting with a letter or number.",
);
}
if ((CONFIG_FILE_KEYS as readonly string[]).includes(name) || name === ACTIVE_CONFIG_KEY) {
throw new BailianError(
`Invalid config name "${name}". It conflicts with a config key.`,
ExitCode.USAGE,
);
}
return name;
}
/** 读完整 config.json 原始对象(不经过 `parseConfigFile` 过滤),保留其他命名配置 block。 */
function readRawConfigObject(): Record<string, unknown> {
const path = getConfigPath();
if (!existsSync(path)) return {};
try {
return parseConfigFile(JSON.parse(readFileSync(path, "utf-8")));
const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
return raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
} catch (err) {
const e = err as Error;
if (e instanceof SyntaxError || e.message.includes("JSON")) {
@@ -20,14 +54,133 @@ export function readConfigFile(): ConfigFile {
}
}
export async function writeConfigFile(data: Record<string, unknown>): Promise<void> {
/** 读取顶层激活元数据;按需校验命名 Profile 必须实际存在。 */
function readStoredActiveConfigName(
raw: Record<string, unknown>,
requireExisting: boolean,
): string | undefined {
const activeConfigName = normalizeConfigName(raw[ACTIVE_CONFIG_KEY]);
if (activeConfigName && requireExisting && !isConfigBlock(raw[activeConfigName])) {
throw new BailianError(
`Active config "${activeConfigName}" does not exist.`,
ExitCode.USAGE,
"Use --config default to select the default config, then activate an existing profile.",
);
}
return activeConfigName;
}
function readRawConfigBlock(raw: Record<string, unknown>, configName?: string): unknown {
if (!configName) return raw;
const block = raw[configName];
return isConfigBlock(block) ? block : {};
}
export function readConfigFile(configName?: string): ConfigFile {
const raw = readRawConfigObject();
return parseConfigFile(readRawConfigBlock(raw, configName));
}
/** 写入所选 Profile登录流程可在同一次原子写入中将显式 Profile 设为激活项。 */
export async function writeConfigFile(
data: Record<string, unknown>,
configName?: string,
options: { activate?: boolean } = {},
): Promise<void> {
const raw = readRawConfigObject();
if (configName) {
raw[configName] = data;
} else {
for (const key of Object.keys(raw)) {
if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) delete raw[key];
}
Object.assign(raw, data);
}
if (options.activate) {
raw[ACTIVE_CONFIG_KEY] = configName ?? "default";
}
await writeRawConfigObject(raw);
}
async function writeRawConfigObject(raw: Record<string, unknown>): Promise<void> {
await ensureConfigDir();
const path = getConfigPath();
const tmp = path + ".tmp";
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
}
/** 全量配置快照:顶层默认配置 + 各命名 profile。 */
export interface ConfigProfiles {
/** 顶层默认配置parseConfigFile 过滤后)。 */
default: ConfigFile;
/** 命名配置 name -> 配置。 */
named: Record<string, ConfigFile>;
/** 当前持久化激活项default 表示顶层配置。 */
active: string;
}
/**
* 读取全部 profile顶层默认配置与各命名 block。
* 命名 block = raw 中不属于 `CONFIG_FILE_KEYS`、且值为普通对象的项。
*/
export function readConfigProfiles(): ConfigProfiles {
const raw = readRawConfigObject();
const named: Record<string, ConfigFile> = {};
for (const [key, value] of Object.entries(raw)) {
if ((CONFIG_FILE_KEYS as readonly string[]).includes(key) || key === ACTIVE_CONFIG_KEY)
continue;
if (isConfigBlock(value)) {
named[key] = parseConfigFile(value);
}
}
return {
default: parseConfigFile(raw),
named,
active: readStoredActiveConfigName(raw, true) ?? "default",
};
}
function resolveConfigProfileActivation(raw: Record<string, unknown>, name?: unknown): string {
const configName = normalizeConfigName(name);
if (configName && !isConfigBlock(raw[configName])) {
throw new BailianError(
`Config "${configName}" does not exist.`,
ExitCode.USAGE,
"Create or log in to the profile before activating it.",
);
}
return configName ?? "default";
}
/** 校验激活目标并返回规范化展示名;不写配置。 */
export function validateConfigProfileActivation(name?: unknown): string {
return resolveConfigProfileActivation(readRawConfigObject(), name);
}
/** 将已存在的命名 Profile或 default设为持久化激活项。 */
export async function activateConfigProfile(name?: unknown): Promise<string> {
const raw = readRawConfigObject();
const active = resolveConfigProfileActivation(raw, name);
raw[ACTIVE_CONFIG_KEY] = active;
await writeRawConfigObject(raw);
return active;
}
/** 删除一个命名 profile block存在才删并回写返回是否有变更。 */
export async function deleteConfigProfile(name?: unknown): Promise<boolean> {
const configName = normalizeConfigName(name);
if (!configName) {
throw new BailianError("Cannot delete the default profile.", ExitCode.USAGE);
}
const raw = readRawConfigObject();
if (!isConfigBlock(raw[configName])) return false;
delete raw[configName];
if (readStoredActiveConfigName(raw, false) === configName) raw[ACTIVE_CONFIG_KEY] = "default";
await writeRawConfigObject(raw);
return true;
}
/**
* 解析的三个来源,dispatch 边界一次构建。flags 收 Partial:ParsedFlags 里 switch 是
* 必填 boolean,收 Partial 让 pipeline 等无 flag 场景传 {} 即可。
@@ -36,10 +189,24 @@ export interface ResolutionSources {
flags: Partial<SourceFlags>;
file: ConfigFile;
env: NodeJS.ProcessEnv;
/** 当前命名配置名(`--config <name>` 解析后);未指定或 `default` 时为 undefined。 */
configName?: string;
/** 实际 config.json 路径(不受 configName 影响,一直是同一个文件)。 */
configPath?: string;
}
export function buildSources(flags: Partial<SourceFlags>): ResolutionSources {
return { flags, file: readConfigFile(), env: process.env };
const raw = readRawConfigObject();
const configExplicit = flags.config !== undefined;
const activeConfigName = readStoredActiveConfigName(raw, !configExplicit);
const configName = configExplicit ? normalizeConfigName(flags.config) : activeConfigName;
return {
flags,
file: parseConfigFile(readRawConfigBlock(raw, configName)),
env: process.env,
configName,
configPath: getConfigPath(),
};
}
/**
@@ -60,13 +227,16 @@ export function buildSettings(s: ResolutionSources): Settings {
}
return {
configPath: getConfigPath(),
configPath: s.configPath ?? getConfigPath(),
configName: s.configName,
output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputDir: file.output_dir || undefined,
timeout,
defaultTextModel: file.default_text_model,
defaultVideoModel: file.default_video_model,
defaultImageToVideoModel: file.default_image_to_video_model,
defaultReferenceToVideoModel: file.default_reference_to_video_model,
defaultImageModel: file.default_image_model,
defaultSpeechModel: file.default_speech_model,
defaultOmniModel: file.default_omni_model,
@@ -0,0 +1,31 @@
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
/**
* Normalize a model-service base URL to its origin.
* CLI endpoints append their own API paths, so user-provided paths, query
* parameters, and fragments must not remain in the stored or resolved base URL.
*/
export function normalizeModelBaseUrl(input: string): string {
const trimmed = input.trim();
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw invalidModelBaseUrl(input);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw invalidModelBaseUrl(input);
}
return parsed.origin;
}
function invalidModelBaseUrl(input: string): BailianError {
return new BailianError(
`Invalid model base URL "${input}".`,
ExitCode.USAGE,
"Use an absolute http(s) URL.",
);
}
@@ -0,0 +1,24 @@
interface ModelProfilePreset {
baseUrl: string;
defaultTextModel: string;
defaultVideoModel: string;
defaultImageToVideoModel: string;
defaultReferenceToVideoModel: string;
defaultImageModel: string;
}
const MODEL_PROFILE_PRESETS: Readonly<Record<string, ModelProfilePreset>> = {
"token-plan": {
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
defaultTextModel: "qwen3.8-max-preview",
defaultVideoModel: "happyhorse-1.1-t2v",
defaultImageToVideoModel: "happyhorse-1.1-i2v",
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
defaultImageModel: "wan2.7-image",
},
};
/** Defaults materialized when logging into a well-known model profile. */
export function getModelProfilePreset(configName?: string): ModelProfilePreset | undefined {
return configName ? MODEL_PROFILE_PRESETS[configName] : undefined;
}
+52 -5
View File
@@ -1,3 +1,5 @@
import { normalizeModelBaseUrl } from "./model-base-url.ts";
export const REGIONS = {
cn: "https://dashscope.aliyuncs.com",
us: "https://dashscope-us.aliyuncs.com",
@@ -22,12 +24,16 @@ export interface ConfigFile {
access_key_id?: string;
/** Alibaba Cloud OpenAPI AccessKey secret from `bl auth login --open-api`. */
access_key_secret?: string;
/** Alibaba Cloud STS Security Token (optional, for temporary credentials). */
security_token?: string;
base_url?: string;
output?: "text" | "json";
output_dir?: string;
timeout?: number;
default_text_model?: string;
default_video_model?: string;
default_image_to_video_model?: string;
default_reference_to_video_model?: string;
default_image_model?: string;
default_speech_model?: string;
default_omni_model?: string;
@@ -38,6 +44,30 @@ export interface ConfigFile {
telemetry?: boolean;
}
export const CONFIG_FILE_KEYS = [
"api_key",
"access_token",
"access_key_id",
"access_key_secret",
"security_token",
"base_url",
"output",
"output_dir",
"timeout",
"default_text_model",
"default_video_model",
"default_image_to_video_model",
"default_reference_to_video_model",
"default_image_model",
"default_speech_model",
"default_omni_model",
"workspace_id",
"console_site",
"console_region",
"console_switch_agent",
"telemetry",
] as const satisfies readonly (keyof ConfigFile)[];
const VALID_OUTPUTS = new Set<string>(["text", "json"]);
const VALID_CONSOLE_SITES = new Set<string>(["domestic", "international"]);
@@ -47,12 +77,11 @@ const VALID_CONSOLE_SITES = new Set<string>(["domestic", "international"]);
* sends the Bearer token to these origins, so a bare `startsWith("http")` check
* (which also accepts e.g. "httpfoo://…") is too loose.
*/
function isHttpUrl(value: string): boolean {
function parseModelBaseUrl(value: string): string | undefined {
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
return normalizeModelBaseUrl(value);
} catch {
return false;
return undefined;
}
}
@@ -77,7 +106,12 @@ export function parseConfigFile(raw: unknown): ConfigFile {
obj.openapi_access_key_secret.length > 0
)
out.access_key_secret = obj.openapi_access_key_secret;
if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url;
if (typeof obj.security_token === "string" && obj.security_token.length > 0)
out.security_token = obj.security_token;
if (typeof obj.base_url === "string") {
const baseUrl = parseModelBaseUrl(obj.base_url);
if (baseUrl) out.base_url = baseUrl;
}
if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output))
out.output = obj.output as ConfigFile["output"];
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
@@ -87,6 +121,16 @@ export function parseConfigFile(raw: unknown): ConfigFile {
out.default_text_model = obj.default_text_model;
if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0)
out.default_video_model = obj.default_video_model;
if (
typeof obj.default_image_to_video_model === "string" &&
obj.default_image_to_video_model.length > 0
)
out.default_image_to_video_model = obj.default_image_to_video_model;
if (
typeof obj.default_reference_to_video_model === "string" &&
obj.default_reference_to_video_model.length > 0
)
out.default_reference_to_video_model = obj.default_reference_to_video_model;
if (typeof obj.default_image_model === "string" && obj.default_image_model.length > 0)
out.default_image_model = obj.default_image_model;
if (typeof obj.default_speech_model === "string" && obj.default_speech_model.length > 0)
@@ -124,6 +168,7 @@ export interface Identity {
*/
export interface Settings {
configPath?: string;
configName?: string;
output: "text" | "json";
/**
* Whether `output` came from an explicit source (flag/env/file) rather than
@@ -135,6 +180,8 @@ export interface Settings {
timeout: number;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
defaultReferenceToVideoModel?: string;
defaultImageModel?: string;
defaultSpeechModel?: string;
defaultOmniModel?: string;
+25 -8
View File
@@ -1,6 +1,14 @@
import type { ConfigFile } from "./schema.ts";
import { readConfigFile, writeConfigFile } from "./loader.ts";
import {
activateConfigProfile,
readConfigFile,
readConfigProfiles,
validateConfigProfileActivation,
writeConfigFile,
type ConfigProfiles,
} from "./loader.ts";
import { getConfigPath } from "./paths.ts";
import { normalizeModelBaseUrl } from "./model-base-url.ts";
/**
* config 命令族的持久化能力面(lint 限定 commands/config/** 使用)。
@@ -12,25 +20,34 @@ export interface ConfigStore {
write(patch: Partial<ConfigFile>): Promise<void>;
/** 删除指定键。 */
unset(keys: (keyof ConfigFile)[]): Promise<void>;
/** 读取所有 Profile 与持久化激活项。 */
profiles(): ConfigProfiles;
/** 激活已存在的命名 Profileundefined/default 激活顶层配置。 */
activate(name?: unknown): Promise<string>;
/** 校验激活目标并返回规范化展示名,不落盘。 */
validateActivation(name?: unknown): string;
path: string;
}
export function makeConfigStore(): ConfigStore {
export function makeConfigStore(configName?: string): ConfigStore {
return {
read: () => readConfigFile(),
read: () => readConfigFile(configName),
async write(patch) {
const existing = readConfigFile() as Record<string, unknown>;
const existing = readConfigFile(configName) as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) delete existing[key];
else existing[key] = value;
else existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value;
}
await writeConfigFile(existing);
await writeConfigFile(existing, configName);
},
async unset(keys) {
const existing = readConfigFile() as Record<string, unknown>;
const existing = readConfigFile(configName) as Record<string, unknown>;
for (const key of keys) delete existing[key];
await writeConfigFile(existing);
await writeConfigFile(existing, configName);
},
profiles: () => readConfigProfiles(),
activate: (name) => activateConfigProfile(name),
validateActivation: (name) => validateConfigProfileActivation(name),
get path() {
return getConfigPath();
},
+27 -14
View File
@@ -13,7 +13,10 @@ interface ConsoleGatewayInfo {
const REGION_GATEWAYS: Record<string, Record<ConsoleSite, ConsoleGatewayInfo>> = {
"cn-beijing": {
domestic: { csGateway: "bailian-cs.console.aliyun.com", action: "BroadScopeAspnGateway" },
domestic: {
csGateway: "bailian-cs.console.aliyun.com",
action: "BroadScopeAspnGateway",
},
international: {
csGateway: "bailian-cs.console.alibabacloud.com",
action: "BroadScopeAspnGateway",
@@ -74,6 +77,7 @@ function buildGatewayParams(
protocol: "V2",
console: "ONE_CONSOLE",
productCode: "p_efm",
switchUserType: 3,
consoleSite: "BAILIAN_ALIYUN",
...(switchAgent != null ? { switchAgent } : {}),
...(typeof data.cornerstoneParam === "object" && data.cornerstoneParam !== null
@@ -100,6 +104,7 @@ export async function callConsoleGateway(
target: ConsoleGatewayTarget,
timeoutSec: number,
{ api, data }: ConsoleGatewayRequest,
settings?: Pick<Settings, "verbose">,
): Promise<unknown> {
const resolved = resolveGateway(target.region, target.site);
const gatewayBase = `https://${resolved.csGateway}`;
@@ -115,15 +120,24 @@ export async function callConsoleGateway(
};
if (target.token) headers.Authorization = `Bearer ${target.token}`;
const res = await fetch(
`${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`,
{
method: "POST",
headers,
body: body.toString(),
signal: AbortSignal.timeout(timeoutMs),
},
);
const endpoint = `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`;
if (settings?.verbose) {
process.stderr.write(`> POST ${endpoint}\n`);
process.stderr.write(
`> payload ${JSON.stringify({ params: JSON.parse(params), region: target.region }, null, 2)}\n`,
);
}
const res = await fetch(endpoint, {
method: "POST",
headers,
body: body.toString(),
signal: AbortSignal.timeout(timeoutMs),
});
if (settings?.verbose) {
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
}
if (!res.ok) {
const t = await res.text().catch(() => "");
@@ -138,11 +152,11 @@ export async function callConsoleGateway(
const innerData = json.data as Record<string, unknown> | undefined;
if (innerData?.success === false && innerData.errorCode) {
const rawResponse = JSON.stringify(json);
const rawErrorCode = innerData.errorCode;
const errorCode =
typeof rawErrorCode === "string" ? rawErrorCode : JSON.stringify(rawErrorCode);
const notLogined = errorCode.includes("NotLogined");
const errorMsg = typeof innerData.errorMsg === "string" ? innerData.errorMsg : undefined;
throw new BailianError(
notLogined
? "Console session is not logged in or has expired."
@@ -150,9 +164,8 @@ export async function callConsoleGateway(
notLogined ? ExitCode.AUTH : ExitCode.GENERAL,
notLogined
? "Run `bl auth login --console` to sign in or refresh your console session."
: errorMsg && errorMsg !== errorCode
? errorMsg
: undefined,
: undefined,
{ rawResponse },
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type { ConsoleGatewayRequest, ConsoleSite } from "./gateway.ts";
export type { ConsoleGatewayRequest, ConsoleGatewayTarget, ConsoleSite } from "./gateway.ts";
export { callConsoleGateway, effectiveConsoleGatewayConfig } from "./gateway.ts";
export type {
ModelListParams,
+3
View File
@@ -9,12 +9,14 @@ export interface ApiErrorContext {
export interface BailianErrorOptions {
cause?: unknown;
api?: ApiErrorContext;
rawResponse?: string;
}
export class BailianError extends Error {
readonly exitCode: ExitCode;
readonly hint?: string;
readonly api?: ApiErrorContext;
readonly rawResponse?: string;
constructor(
message: string,
@@ -27,6 +29,7 @@ export class BailianError extends Error {
this.exitCode = exitCode;
this.hint = hint;
this.api = options?.api;
this.rawResponse = options?.rawResponse;
}
toJSON() {
+7 -1
View File
@@ -1 +1,7 @@
export { uploadFile, isLocalFile, resolveFileUrl } from "./upload.ts";
export {
uploadFile,
isLocalFile,
resolveFileUrl,
imageFileToDataUri,
redactDataUri,
} from "./upload.ts";
+44 -1
View File
@@ -6,7 +6,7 @@
* X-DashScope-OssResourceResolve: enable
*/
import { existsSync, readFileSync, statSync } from "fs";
import { basename } from "path";
import { basename, extname } from "path";
import { BailianError } from "../errors/base.ts";
import { ExitCode } from "../errors/codes.ts";
import { trackingHeaders } from "../client/headers.ts";
@@ -112,6 +112,49 @@ export interface UploadOptions {
signal?: AbortSignal;
}
const IMAGE_MIME_TYPES: Readonly<Record<string, string>> = {
".bmp": "image/bmp",
".heic": "image/heic",
".jpe": "image/jpeg",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".tif": "image/tiff",
".tiff": "image/tiff",
".webp": "image/webp",
};
/** Encode a local image as a Data URI. */
export function imageFileToDataUri(filePath: string): string {
if (!existsSync(filePath)) {
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
}
const stat = statSync(filePath);
if (!stat.isFile()) {
throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE);
}
const extension = extname(filePath).toLowerCase();
const mimeType = IMAGE_MIME_TYPES[extension];
if (!mimeType) {
throw new BailianError(
`Unsupported image format "${extension || "unknown"}".`,
ExitCode.USAGE,
"Use an image file with a recognized extension.",
);
}
const encoded = readFileSync(filePath).toString("base64");
return `data:${mimeType};base64,${encoded}`;
}
/** Keep dry-run output readable and avoid echoing the complete inline image. */
export function redactDataUri(input: string): string {
const match = /^data:([^;,]+);base64,/i.exec(input);
return match ? `data:${match[1]};base64,<omitted>` : input;
}
/**
* Upload a local file to DashScope temporary storage and return the oss:// URL.
* The URL is valid for 48 hours.
+10
View File
@@ -72,6 +72,11 @@ export const GLOBAL_FLAGS = {
quiet: { type: "switch", description: "Suppress non-essential output" },
verbose: { type: "switch", description: "Print HTTP request/response details" },
dryRun: { type: "switch", description: "Dry run mode" },
config: {
type: "string",
valueHint: "<name>",
description: "Use a config profile for this command",
},
help: { type: "switch", description: "Show help" },
version: { type: "switch", description: "Print version" },
} satisfies FlagsDef;
@@ -132,6 +137,11 @@ export const OPENAPI_AUTH_FLAGS = {
valueHint: "<key>",
description: "Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET)",
},
securityToken: {
type: "string",
valueHint: "<token>",
description: "Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN)",
},
} satisfies FlagsDef;
/** sources 里可能出现的全部 flag(全局 + 凭证域)。 */
+3 -1
View File
@@ -1,5 +1,7 @@
import { readFileSync } from "fs";
const STDIN_FILE_DESCRIPTOR = 0;
export function readTextFromPathOrStdin(path: string): string {
return readFileSync(path === "-" ? "/dev/stdin" : path, "utf-8");
return readFileSync(path === "-" ? STDIN_FILE_DESCRIPTOR : path, "utf-8");
}
+1
View File
@@ -2,6 +2,7 @@ export { generateFilename } from "./filename.ts";
export { resolveOutputDir } from "./output-dir.ts";
export { maskToken } from "./token.ts";
export { stripUndefined } from "./object.ts";
export { readTextFromPathOrStdin } from "./fs.ts";
export {
parseBooleanValue,
parseOptionalBooleanValue,
+86 -10
View File
@@ -7,31 +7,99 @@ import {
resolveModelBaseUrl,
resolveOpenApi,
} from "../src/auth/resolver.ts";
import { getModelProfilePreset } from "../src/config/profile-presets.ts";
// 行为锁定:锁住各字段的 flag/env/file 优先级链,统一为 flag>env>file>默认
// (baseUrl 原为 flag>file>env,2026-07 前置 commit 翻转)。buildSettings 与
// resolver 都是纯函数,sources 直接构造,无需环境隔离。
// 行为锁定:所有配置字段统一保持 flag>env>selected file>默认。`--config` 只选择
// file block,不提升该 block 的字段优先级。Profile 预设只在登录写入阶段使用。
// buildSettings 与 resolver 都是纯函数,sources 直接构造,无需环境隔离。
function src(s: {
flags?: ResolutionSources["flags"];
env?: Record<string, string>;
file?: ConfigFile;
configName?: string;
}): ResolutionSources {
return { flags: s.flags ?? {}, file: s.file ?? {}, env: s.env ?? {} };
return {
flags: s.flags ?? {},
file: s.file ?? {},
env: s.env ?? {},
configName: s.configName,
};
}
const resolve = (s: Parameters<typeof src>[0]): Settings => buildSettings(src(s));
test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => {
const flags = { baseUrl: "https://flag.example.com" };
const env = { DASHSCOPE_BASE_URL: "https://env.example.com" };
const file: ConfigFile = { base_url: "https://file.example.com" };
test("token-plan Profile 预设保持固定", () => {
expect(getModelProfilePreset("token-plan")).toEqual({
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
defaultTextModel: "qwen3.8-max-preview",
defaultVideoModel: "happyhorse-1.1-t2v",
defaultImageToVideoModel: "happyhorse-1.1-i2v",
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
defaultImageModel: "wan2.7-image",
});
});
test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => {
const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" };
const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" };
const file: ConfigFile = { base_url: "https://file.example.com/gateway/" };
expect(resolveModelBaseUrl(src({ flags, env, file }))).toBe("https://flag.example.com");
expect(resolveModelBaseUrl(src({ env, file }))).toBe("https://env.example.com");
expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com");
expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com");
});
test("baseUrl:非法 flag/env 在 resolver 边界报 usage error", () => {
expect(() => resolveModelBaseUrl(src({ flags: { baseUrl: "not-a-url" } }))).toThrow(
/Invalid model base URL/,
);
expect(() =>
resolveModelBaseUrl(src({ env: { DASHSCOPE_BASE_URL: "file:///tmp/model" } })),
).toThrow(/Invalid model base URL/);
});
test("命名 config 仍保持 flag > env > selected file", () => {
const env = {
DASHSCOPE_BASE_URL: "https://env.example.com",
DASHSCOPE_API_KEY: "sk-env",
};
const sources = src({
configName: "token-plan",
env,
file: {
api_key: "sk-token-plan",
base_url: "https://profile.example.com",
default_text_model: "custom-text",
default_image_model: "custom-image",
},
});
expect(resolveModelBaseUrl(sources)).toBe("https://env.example.com");
expect(resolveApiKey(sources)).toMatchObject({
token: "sk-env",
baseUrl: "https://env.example.com",
source: "env",
});
expect(buildSettings(sources)).toMatchObject({
defaultTextModel: "custom-text",
defaultImageModel: "custom-image",
});
expect(
resolveApiKey(
src({
configName: "token-plan",
flags: { apiKey: "sk-flag", baseUrl: "https://flag.example.com" },
env,
file: sources.file,
}),
),
).toMatchObject({
token: "sk-flag",
baseUrl: "https://flag.example.com",
source: "flag",
});
});
test("output:flag > env > file > text", () => {
const env = { DASHSCOPE_OUTPUT: "json" };
const file: ConfigFile = { output: "json" };
@@ -251,10 +319,18 @@ test("openapi 凭证:低优先级来源缺字段时不影响更高优先级成
test("default*Model / outputDir:仅 file 源", () => {
const c = resolve({
file: { default_text_model: "qwen-max", default_video_model: "wan-x", output_dir: "/tmp/out" },
file: parseConfigFile({
default_text_model: "qwen-max",
default_video_model: "wan-t2v",
default_image_to_video_model: "wan-i2v",
default_reference_to_video_model: "wan-r2v",
output_dir: "/tmp/out",
}),
});
expect(c.defaultTextModel).toBe("qwen-max");
expect(c.defaultVideoModel).toBe("wan-x");
expect(c.defaultVideoModel).toBe("wan-t2v");
expect(c.defaultImageToVideoModel).toBe("wan-i2v");
expect(c.defaultReferenceToVideoModel).toBe("wan-r2v");
expect(c.outputDir).toBe("/tmp/out");
expect(resolve({}).defaultTextModel).toBeUndefined();
});
+301 -1
View File
@@ -1,9 +1,21 @@
import { mkdtempSync, rmSync } from "fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { expect, test } from "vite-plus/test";
import { makeConfigStore } from "../src/config/store.ts";
import { makeAuthStore } from "../src/auth/store.ts";
import { refreshAccessToken } from "../src/auth/refresh-token.ts";
import {
buildSettings,
buildSources,
normalizeConfigName,
readConfigFile,
writeConfigFile,
readConfigProfiles,
activateConfigProfile,
deleteConfigProfile,
} from "../src/config/loader.ts";
import { getConfigPath } from "../src/config/paths.ts";
/** 在隔离的临时配置目录里执行,结束后恢复环境。 */
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
@@ -36,17 +48,42 @@ test("ConfigStore:write 合并写入,undefined 键删除,unset 删键", async ()
});
});
test("ConfigStore/AuthStore 写入前归一化 model Base URL", async () => {
await inTempConfigDir(async () => {
const configStore = makeConfigStore();
await configStore.write({
base_url: "https://proxy.example.com/bailian/compatible-mode/v1/?query=one#fragment",
});
expect(readConfigFile().base_url).toBe("https://proxy.example.com");
expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe(
"https://proxy.example.com",
);
const authStore = makeAuthStore(buildSources({}));
await authStore.login({ base_url: "https://token.example.com/apps/anthropic/" });
expect(readConfigFile().base_url).toBe("https://token.example.com");
expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe(
"https://token.example.com",
);
});
});
test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () => {
await inTempConfigDir(async () => {
const store = makeAuthStore({ flags: {}, file: {}, env: {} });
await store.login({
api_key: "sk-1",
base_url: "https://model.example.com/compatible-mode/v1",
access_token: "tok-1",
access_key_id: "ak-1",
access_key_secret: "secret-1",
security_token: "sts-1",
workspace_id: "ws-1",
console_site: "international",
});
expect(makeConfigStore().read()).toMatchObject({
api_key: "sk-1",
base_url: "https://model.example.com",
access_token: "tok-1",
workspace_id: "ws-1",
console_site: "international",
@@ -55,12 +92,275 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async ()
expect(await store.logout("console")).toBe(true);
expect(makeConfigStore().read().access_token).toBeUndefined();
expect(makeConfigStore().read().api_key).toBe("sk-1");
expect(makeConfigStore().read().base_url).toBe("https://model.example.com");
expect(await store.logout("openapi")).toBe(true);
expect(makeConfigStore().read()).toMatchObject({ api_key: "sk-1" });
expect(makeConfigStore().read().base_url).toBe("https://model.example.com");
expect(makeConfigStore().read().access_key_id).toBeUndefined();
expect(makeConfigStore().read().access_key_secret).toBeUndefined();
expect(makeConfigStore().read().security_token).toBeUndefined();
expect(await store.logout("all")).toBe(true);
expect(makeConfigStore().read().api_key).toBeUndefined();
expect(makeConfigStore().read().base_url).toBeUndefined();
expect(await store.logout("all")).toBe(false);
// 非凭证键不受 logout 影响
expect(makeConfigStore().read().workspace_id).toBe("ws-1");
});
});
test("AuthStore:未传 --config 时写当前激活项,显式配置在登录成功后创建并激活", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default" });
await writeConfigFile({ access_token: "tok-dev" }, "dev");
await activateConfigProfile("dev");
const activeStore = makeAuthStore(buildSources({}));
await activeStore.login({ access_token: "tok-dev-updated", workspace_id: "ws-dev" });
expect(readConfigFile("dev")).toMatchObject({
access_token: "tok-dev-updated",
workspace_id: "ws-dev",
});
expect(readConfigFile().api_key).toBe("sk-default");
expect(readConfigFile().access_token).toBeUndefined();
const newStore = makeAuthStore(buildSources({ config: "new-profile" }));
await newStore.login({ access_token: "tok-new" });
expect(readConfigFile("new-profile").access_token).toBe("tok-new");
expect(readConfigProfiles().active).toBe("new-profile");
const defaultStore = makeAuthStore(buildSources({ config: "default" }));
await defaultStore.login({ api_key: "sk-default-updated" });
expect(readConfigFile().api_key).toBe("sk-default-updated");
expect(readConfigProfiles().active).toBe("default");
expect(await activeStore.logout("console")).toBe(true);
expect(readConfigFile("dev").access_token).toBeUndefined();
expect(readConfigFile("new-profile").access_token).toBe("tok-new");
});
});
test("Console access token 自动刷新只读取当前选中 Config 的 AK/SK", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({
access_key_id: "ak-default",
access_key_secret: "secret-default",
});
await writeConfigFile({ access_token: "expired-dev" }, "dev");
await activateConfigProfile("dev");
const sources = buildSources({});
const refreshed = await refreshAccessToken({
identity: {
binName: "bl",
version: "0.0.0-test",
npmPackage: "bailian-cli",
clientName: "bailian-cli-test",
},
settings: buildSettings(sources),
baseUrl: "https://dashscope.aliyuncs.com",
});
expect(refreshed).toBeNull();
expect(readConfigFile("dev").access_token).toBe("expired-dev");
});
});
test("Console access token 自动刷新写回当前选中 Config", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ access_token: "tok-default" });
await writeConfigFile(
{
access_token: "expired-dev",
access_key_id: "ak-dev",
access_key_secret: "secret-dev",
},
"dev",
);
await activateConfigProfile("dev");
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ cliAccessToken: "refreshed-dev" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
try {
const sources = buildSources({});
const refreshed = await refreshAccessToken({
identity: {
binName: "bl",
version: "0.0.0-test",
npmPackage: "bailian-cli",
clientName: "bailian-cli-test",
},
settings: buildSettings(sources),
baseUrl: "https://dashscope.aliyuncs.com",
});
expect(refreshed).toBe("refreshed-dev");
expect(readConfigFile("dev").access_token).toBe("refreshed-dev");
expect(readConfigFile().access_token).toBe("tok-default");
} finally {
globalThis.fetch = originalFetch;
}
});
});
test("ConfigStore:命名 config 与默认配置隔离且写入保留其它 block", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default", output: "json" });
await writeConfigFile({ api_key: "sk-prod", output: "text" }, "prod");
const dev = makeConfigStore("dev");
await dev.write({ api_key: "sk-dev", timeout: 120 });
expect(makeConfigStore().read()).toMatchObject({ api_key: "sk-default", output: "json" });
expect(dev.read()).toMatchObject({ api_key: "sk-dev", timeout: 120 });
expect(makeConfigStore("prod").read()).toMatchObject({ api_key: "sk-prod", output: "text" });
expect(readConfigFile("dev")).not.toMatchObject({ output: "json" });
expect(dev.path).toBe(getConfigPath());
});
});
test("AuthStore:login/logout 只影响当前命名 config", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default", access_token: "tok-default" });
const sources = buildSources({ config: "dev" });
const store = makeAuthStore(sources);
await store.login({ api_key: "sk-dev", access_token: "tok-dev", workspace_id: "ws-dev" });
expect(makeConfigStore().read()).toMatchObject({
api_key: "sk-default",
access_token: "tok-default",
});
expect(makeConfigStore("dev").read()).toMatchObject({
api_key: "sk-dev",
access_token: "tok-dev",
workspace_id: "ws-dev",
});
expect(await store.logout("console")).toBe(true);
expect(makeConfigStore("dev").read().access_token).toBeUndefined();
expect(makeConfigStore().read().access_token).toBe("tok-default");
});
});
test("config name 校验拒绝路径穿越和 ConfigFile 字段冲突", () => {
expect(normalizeConfigName("dev_1")).toBe("dev_1");
expect(normalizeConfigName("default")).toBeUndefined();
expect(() => normalizeConfigName("../evil")).toThrow(/Invalid config name/);
expect(() => normalizeConfigName("api_key")).toThrow(/conflicts with a config key/);
expect(() => normalizeConfigName("active_config")).toThrow(/conflicts with a config key/);
});
test("readConfigProfiles 分离 default 与 named,deleteConfigProfile 只删指定 block", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default", output: "json" });
await writeConfigFile({ api_key: "sk-prod" }, "prod");
await writeConfigFile({ access_token: "tok-dev" }, "dev");
const profiles = readConfigProfiles();
expect(profiles.active).toBe("default");
expect(profiles.default).toMatchObject({ api_key: "sk-default", output: "json" });
expect(Object.keys(profiles.named).sort()).toEqual(["dev", "prod"]);
expect(profiles.named.prod).toMatchObject({ api_key: "sk-prod" });
expect(profiles.named.dev).toMatchObject({ access_token: "tok-dev" });
expect(await deleteConfigProfile("prod")).toBe(true);
const after = readConfigProfiles();
expect(after.named.prod).toBeUndefined();
expect(after.named.dev).toMatchObject({ access_token: "tok-dev" });
expect(after.default).toMatchObject({ api_key: "sk-default" });
// 再次删除不存在的 block 返回 false
expect(await deleteConfigProfile("prod")).toBe(false);
await expect(deleteConfigProfile("default")).rejects.toThrow(/Cannot delete the default/);
await expect(deleteConfigProfile("api_key")).rejects.toThrow(/conflicts with a config key/);
expect(readConfigFile().api_key).toBe("sk-default");
});
});
test("active_config:未配置时使用 default激活命名 Profile 后无 flag 自动选择", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default" });
await writeConfigFile({ api_key: "sk-token", default_text_model: "qwen3.7-max" }, "token-plan");
expect(buildSources({}).configName).toBeUndefined();
expect(await activateConfigProfile("token-plan")).toBe("token-plan");
const activeSources = buildSources({});
expect(activeSources.configName).toBe("token-plan");
expect(activeSources.file.api_key).toBe("sk-token");
expect(readConfigProfiles().active).toBe("token-plan");
});
});
test("显式 --config 优先于 active_config--config default 可绕过激活项", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default" });
await writeConfigFile({ api_key: "sk-active" }, "active");
await writeConfigFile({ api_key: "sk-other" }, "other");
await activateConfigProfile("active");
const explicitDefault = buildSources({ config: "default" });
expect(explicitDefault.configName).toBeUndefined();
expect(explicitDefault.file.api_key).toBe("sk-default");
const explicitActive = buildSources({ config: "active" });
expect(explicitActive.configName).toBe("active");
const explicitOther = buildSources({ config: "other" });
expect(explicitOther.configName).toBe("other");
expect(explicitOther.file.api_key).toBe("sk-other");
expect(readConfigProfiles().active).toBe("active");
});
});
test("激活不存在 Profile 不写盘;悬空 active_config 不静默回退", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default" });
await expect(activateConfigProfile("missing")).rejects.toThrow(/does not exist/);
expect(readConfigProfiles().active).toBe("default");
const configPath = getConfigPath();
writeFileSync(
configPath,
JSON.stringify({ api_key: "sk-default", active_config: "missing" }, null, 2) + "\n",
);
expect(() => buildSources({})).toThrow(/Active config "missing" does not exist/);
const explicitDefault = buildSources({ config: "default" });
expect(explicitDefault.file.api_key).toBe("sk-default");
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("missing");
});
});
test("删除当前激活 Profile 时原子切回 default", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-dev" }, "dev");
await activateConfigProfile("dev");
expect(await deleteConfigProfile("dev")).toBe(true);
expect(readConfigProfiles()).toMatchObject({ active: "default", named: {} });
expect(buildSources({}).configName).toBeUndefined();
});
});
test("buildSources 暴露命名 config 且 default 等价顶层", async () => {
await inTempConfigDir(async () => {
await writeConfigFile({ api_key: "sk-default", output: "json" });
await writeConfigFile({ access_token: "tok-dev" }, "dev");
const defaultSources = buildSources({ config: "default" });
expect(defaultSources.configName).toBeUndefined();
expect(defaultSources.file.api_key).toBe("sk-default");
const devSources = buildSources({ config: "dev" });
expect(devSources.configName).toBe("dev");
expect(devSources.configPath).toBe(getConfigPath());
expect(devSources.file.access_token).toBe("tok-dev");
expect(devSources.file.api_key).toBeUndefined();
});
});
+91
View File
@@ -0,0 +1,91 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { Client } from "../src/client/client.ts";
import { imageFileToDataUri, redactDataUri } from "../src/files/upload.ts";
import type { Settings } from "../src/config/schema.ts";
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function makeImage(extension = ".png", content = Buffer.from([1, 2, 3, 4])): string {
const tempDir = mkdtempSync(join(tmpdir(), "bailian-image-input-"));
tempDirs.push(tempDir);
const filePath = join(tempDir, `input${extension}`);
writeFileSync(filePath, content);
return filePath;
}
function makeSettings(configName?: string): Settings {
return {
configName,
output: "json",
outputExplicit: false,
timeout: 30,
verbose: false,
quiet: true,
dryRun: false,
telemetry: false,
};
}
function makeClient(baseUrl: string, configName?: string): Client {
return new Client({
identity: {
binName: "bl",
version: "test",
npmPackage: "bailian-cli",
clientName: "bailian-cli-test",
},
settings: makeSettings(configName),
baseUrl,
});
}
describe("Token Plan image input compatibility", () => {
test("encodes supported local images and redacts previews", () => {
const imagePath = makeImage(".png");
const dataUri = imageFileToDataUri(imagePath);
expect(dataUri).toBe("data:image/png;base64,AQIDBA==");
expect(redactDataUri(dataUri)).toBe("data:image/png;base64,<omitted>");
});
test("rejects files whose image MIME type cannot be inferred", () => {
const imagePath = makeImage(".unknown");
expect(() => imageFileToDataUri(imagePath)).toThrow(/Unsupported image format/);
});
test("uses Data URI for the token-plan profile even through a custom proxy", async () => {
const imagePath = makeImage(".webp");
const client = makeClient("https://proxy.example.com/bailian", "token-plan");
await expect(client.resolveImageInput(imagePath, "happyhorse-1.1-i2v")).resolves.toMatch(
/^data:image\/webp;base64,/,
);
});
test("uses Data URI for an official Token Plan endpoint under any profile name", async () => {
const imagePath = makeImage(".jpg");
const client = makeClient("https://token-plan.ap-southeast-1.maas.aliyuncs.com", "custom-plan");
await expect(client.resolveImageInput(imagePath, "wan2.7-image")).resolves.toMatch(
/^data:image\/jpeg;base64,/,
);
});
test("ordinary endpoints retain the existing upload path", () => {
const imagePath = makeImage(".png");
const client = makeClient("https://dashscope.aliyuncs.com", "default");
expect(() => client.resolveImageInput(imagePath, "wan2.7-image")).toThrow(
/model-domain API key/,
);
});
});
+113 -4
View File
@@ -1,6 +1,13 @@
import { expect, test } from "vite-plus/test";
import type { Identity, Settings } from "../src/index.ts";
import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts";
import {
BailianError,
ExitCode,
McpClient,
callConsoleGateway,
mapApiError,
request,
} from "../src/index.ts";
import { parseConfigFile } from "../src/config/schema.ts";
import {
parseBooleanValue,
@@ -8,7 +15,10 @@ import {
resolveWatermark,
} from "../src/utils/boolean-flag.ts";
function testDeps(identity: Partial<Identity> = {}): { identity: Identity; settings: Settings } {
function testDeps(identity: Partial<Identity> = {}): {
identity: Identity;
settings: Settings;
} {
return {
identity: {
binName: "bl",
@@ -83,7 +93,10 @@ test("BailianError propagates cause via options-bag and exposes it in toJSON", (
test("toJSON splits service-error metadata into structured fields", () => {
const err = mapApiError(404, {
error: { message: "The model `qwen3.7` does not exist", type: "invalid_request_error" },
error: {
message: "The model `qwen3.7` does not exist",
type: "invalid_request_error",
},
request_id: "c55e1acc",
});
expect(err.toJSON()).toEqual({
@@ -97,6 +110,96 @@ test("toJSON splits service-error metadata into structured fields", () => {
});
});
test("callConsoleGateway verbose prints structured request payload", async () => {
const originalFetch = globalThis.fetch;
const originalWrite = process.stderr.write.bind(process.stderr);
let stderr = "";
let requestBody: string | undefined;
globalThis.fetch = async (_url, init) => {
requestBody = init?.body as string | undefined;
return new Response(JSON.stringify({ data: { success: true, value: "response-body" } }), {
status: 200,
statusText: "OK",
headers: { "Content-Type": "application/json" },
});
};
process.stderr.write = ((chunk: string | Uint8Array) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
try {
await callConsoleGateway(
{
region: "ap-southeast-1",
site: "international",
switchAgent: 123,
token: "token",
},
30,
{
api: "test.api",
data: { workspaceId: "ws-1", cornerstoneParam: { custom: "value" } },
},
{ verbose: true },
);
} finally {
globalThis.fetch = originalFetch;
process.stderr.write = originalWrite;
}
expect(requestBody).toBeDefined();
expect(stderr).toContain('> payload {\n "params": {');
expect(stderr).toContain(' "region": "ap-southeast-1"');
expect(stderr).toContain(' "Api": "test.api"');
expect(stderr).toContain(' "workspaceId": "ws-1"');
expect(stderr).toContain(' "switchUserType": 3');
expect(stderr).toContain(' "switchAgent": 123');
expect(stderr).toContain(' "custom": "value"');
expect(stderr).toContain("< 200 OK");
expect(stderr).not.toContain("response-body");
});
test("callConsoleGateway keeps readable message and raw gateway response separately", async () => {
const originalFetch = globalThis.fetch;
const originalWrite = process.stderr.write.bind(process.stderr);
const responseBody = {
data: {
success: false,
errorCode: "BailianGateway.Team.NotAuthorised",
errorMsg: "team not authorised",
},
};
globalThis.fetch = async () =>
new Response(JSON.stringify(responseBody), {
status: 200,
statusText: "OK",
headers: { "Content-Type": "application/json" },
});
process.stderr.write = (() => true) as typeof process.stderr.write;
try {
await expect(
callConsoleGateway(
{ region: "cn-beijing", site: "domestic", token: "token" },
30,
{ api: "test.api", data: {} },
{ verbose: true },
),
).rejects.toMatchObject({
message: "Console gateway error: BailianGateway.Team.NotAuthorised",
rawResponse: JSON.stringify(responseBody),
exitCode: ExitCode.GENERAL,
});
} finally {
globalThis.fetch = originalFetch;
process.stderr.write = originalWrite;
}
});
test("request uses injected client identity for User-Agent", async () => {
const originalFetch = globalThis.fetch;
let userAgent: string | undefined;
@@ -160,7 +263,9 @@ test("McpClient uses injected client identity for initialize and User-Agent", as
userAgents.push(headers?.["User-Agent"] ?? "");
const body = init?.body;
if (typeof body === "string") bodies.push(JSON.parse(body));
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), { status: 200 });
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), {
status: 200,
});
};
try {
@@ -213,6 +318,10 @@ test("parseConfigFile accepts only well-formed http(s) base_url", () => {
expect(parseConfigFile({ base_url: "http://localhost:8080" }).base_url).toBe(
"http://localhost:8080",
);
expect(
parseConfigFile({ base_url: "https://proxy.example.com/team/compatible-mode/v1?x=1#y" })
.base_url,
).toBe("https://proxy.example.com");
// Previously accepted because the value merely "starts with http".
expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined();
expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined();
@@ -0,0 +1,31 @@
import { expect, test } from "vite-plus/test";
import { BailianError } from "../src/errors/base.ts";
import { normalizeModelBaseUrl } from "../src/config/model-base-url.ts";
test("normalizeModelBaseUrl keeps only the URL origin", () => {
expect(normalizeModelBaseUrl(" https://dashscope.aliyuncs.com/?region=cn#docs ")).toBe(
"https://dashscope.aliyuncs.com",
);
expect(
normalizeModelBaseUrl(
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
),
).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com");
expect(normalizeModelBaseUrl("https://example.com/api/v1/agentstudio")).toBe(
"https://example.com",
);
expect(
normalizeModelBaseUrl(
"https://example.com/api/v1/services/aigc/image-generation/generation?model=qwen#docs",
),
).toBe("https://example.com");
});
test("normalizeModelBaseUrl preserves explicit ports while removing paths", () => {
expect(normalizeModelBaseUrl("http://localhost:8080/bailian/")).toBe("http://localhost:8080");
});
test("normalizeModelBaseUrl rejects non-http and malformed URLs", () => {
expect(() => normalizeModelBaseUrl("not a url")).toThrow(BailianError);
expect(() => normalizeModelBaseUrl("ftp://example.com/path")).toThrow(/Invalid model base URL/);
});
+13 -4
View File
@@ -1,4 +1,4 @@
import { readConfigFile } from "bailian-cli-core";
import { buildSources } from "bailian-cli-core";
/** 显式开启后才跑真实网络 E2E */
export function isBailianE2EEnabled(): boolean {
@@ -10,8 +10,8 @@ export function isDashScopeE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
if (process.env.DASHSCOPE_API_KEY?.trim()) return true;
try {
const f = readConfigFile();
return typeof f.api_key === "string" && f.api_key.length > 0;
const config = buildSources({}).file;
return typeof config.api_key === "string" && config.api_key.length > 0;
} catch {
return false;
}
@@ -21,13 +21,22 @@ export function isDashScopeE2EReady(): boolean {
export function isConsoleE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
try {
const config = readConfigFile();
const config = buildSources({}).file;
return typeof config.access_token === "string" && config.access_token.length > 0;
} catch {
return false;
}
}
/** OpenAPI AK/SK 真实 E2E 就绪检查:只使用 `.env` / 进程环境中的完整凭证对。 */
export function isOpenApiE2EReady(): boolean {
if (!isBailianE2EEnabled()) return false;
return Boolean(
process.env.ALIBABA_CLOUD_ACCESS_KEY_ID?.trim() &&
process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET?.trim(),
);
}
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 跳过) */
export function isBailianE2EMediaEnabled(): boolean {
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
+3
View File
@@ -29,6 +29,9 @@ BAILIAN_E2E_VIDEO=1
DASHSCOPE_BASE_URL=
# DashScope API Key
DASHSCOPE_API_KEY=
# Alibaba Cloud OpenAPI AccessKey
ALIBABA_CLOUD_ACCESS_KEY_ID=
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
# -------------------------------
BAILIAN_E2E_VIDEO_TASK_ID=b499a8cb-1fc4-4d43-9495-e23c7f78ae0d
# -------------------------------
+2 -2
View File
@@ -5,7 +5,7 @@
**Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.**
[![npm version](https://img.shields.io/npm/v/knowledge-studio-cli?color=0969da&label=npm)](https://www.npmjs.com/package/knowledge-studio-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -23,7 +23,7 @@
npm install -g knowledge-studio-cli
```
> Requires Node.js >= 22.12.
> Requires Node.js >= 18.17.
## Quick Start
+2 -2
View File
@@ -5,7 +5,7 @@
**阿里云 Model Studio 轻量级 RAG 命令行工具 — 专注知识库检索。**
[![npm version](https://img.shields.io/npm/v/knowledge-studio-cli?color=0969da&label=npm)](https://www.npmjs.com/package/knowledge-studio-cli)
[![Node.js](https://img.shields.io/badge/node-%3E%3D22.12-brightgreen)](https://nodejs.org)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.17-brightgreen)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6)](https://www.typescriptlang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)
@@ -23,7 +23,7 @@
npm install -g knowledge-studio-cli
```
> 需要 Node.js >= 22.12
> 需要 Node.js >= 18.17
## 快速开始
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.8.2",
"version": "1.10.1",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
@@ -66,6 +66,6 @@
"yaml": "catalog:"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.8.2",
"version": "1.10.1",
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -56,7 +56,7 @@
"yaml": "catalog:"
},
"engines": {
"node": ">=22.12.0"
"node": ">=18.17.0"
},
"inlinedDependencies": {
"ajv": "8.20.0",
+1 -1
View File
@@ -183,7 +183,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
flags: ownFlags,
settings,
sources,
configStore: makeConfigStore(),
configStore: makeConfigStore(sources.configName),
authStore: makeAuthStore(sources),
commandPacks: commandPackManager,
client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }),

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