mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Merge branch 'main' into feat/bailian-wiki-doc-sync
# Conflicts: # packages/cli/src/commands.ts # packages/commands/tests/e2e/topic-routes.ts # pnpm-lock.yaml # pnpm-workspace.yaml # skills/bailian-cli/reference/index.md
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -56,21 +56,23 @@ 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 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
|
||||
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
|
||||
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
|
||||
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
|
||||
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
|
||||
|
||||
@@ -6,6 +6,101 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config agent --key` / `--region`** — run commands generated by the Model Studio web console as-is: `--key` accepts the console's encoded API key and decodes it locally (use instead of `--api-key`), and `--region` derives the Token Plan endpoint from a region name (use instead of `--base-url`).
|
||||
- **`bl config agent --context-window`** — set the context window written to the OpenClaw configuration (default 256000).
|
||||
- **`bl config agent --wire-api`** — choose the wire protocol written to the Codex configuration; `chat` is kept for legacy Codex 0.80.0 and earlier (a warning is shown).
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl config agent` for Codex now writes `wire_api = "responses"` by default, matching current Codex releases that no longer accept `chat`.
|
||||
- `bl config agent` for Qwen Code now writes the `DASHSCOPE_API_KEY` environment variable instead of `BAILIAN_CLI_API_KEY`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl config agent` configurations now match each agent's official format: Claude Code honors `CLAUDE_CONFIG_DIR` and removes a stale `ANTHROPIC_API_KEY`; Qwen Code uses the v3 settings schema and writes credentials so a system-level `OPENAI_API_KEY` no longer takes precedence; OpenCode accepts JSONC config files (comments and trailing commas); OpenClaw registers the primary model in the model allowlist with complete cost metadata; Hermes uses the official flat `model.*` layout; Codex writes the official `env_key` with an `auth.json` fallback.
|
||||
- `bl config agent` now preserves existing user configuration when writing: it merges instead of overwriting, avoids duplicate provider entries, and keeps custom display names.
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- MCP tools and WebSearch now provide activation guidance and direct marketplace links when Bailian reports that the corresponding service is not activated. WebSearch also guides users with legacy SSE connections to reactivate the service using Streamable HTTP.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed text chat and API Key validation compatibility failures caused by sending unsupported `enable_thinking` values. Text chat now sends the parameter only when thinking is explicitly enabled, while validation uses a compatible model without sending it.
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- `bl image edit` now supports `--function` for specifying edit operations with Wanx image-edit models such as `wanx2.1-imageedit`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed image generation and editing failures and incorrect size parameters for some image models, improving compatibility with Qwen-Image, Wan/Wanx, Z-Image, and dated `wanx-v1` variants.
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl managed-agent`** — declaratively manage Managed Agent infrastructure through a unified CLI. The Bailian provider connects to AgentStudio, with Claude, Qoder, and Ark providers also supported:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` — initialize and validate `agents.yaml`, preview and apply resource changes, and destroy managed resources.
|
||||
- `state list` / `state show` / `state rm` / `state import` — inspect and manage local resource state, including adopting an existing remote resource or removing it from local state without destroying it remotely.
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` — manage the full session lifecycle with streaming responses and structured `--output json` output.
|
||||
- `skill-list` — browse custom and official skills; use `--source all` to return both catalogs in one call.
|
||||
|
||||
### Changed
|
||||
|
||||
- Model Base URLs are now normalized to the URL origin; paths, query parameters, and fragments supplied in the Base URL are no longer included when constructing API request paths.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The installation guide no longer recommends the removed `--non-interactive` flag and now documents explicit required arguments, `--output json`, and `NO_COLOR=1` for non-interactive environments.
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- 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
|
||||
|
||||
@@ -6,6 +6,101 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent --key` / `--region`** —— 百炼控制台生成的命令可直接运行:`--key` 接收控制台编码后的 API Key 并在本地解码(与 `--api-key` 二选一);`--region` 根据地域名自动派生 Token Plan 接入地址(与 `--base-url` 二选一)。
|
||||
- **`bl config agent --context-window`** —— 设置写入 OpenClaw 配置的上下文窗口大小(默认 256000)。
|
||||
- **`bl config agent --wire-api`** —— 选择写入 Codex 配置的通信协议;`chat` 仅保留给 Codex 0.80.0 及更早版本(会显示警告)。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl config agent` 配置 Codex 时默认写入 `wire_api = "responses"`,以适配已不再支持 `chat` 的新版 Codex。
|
||||
- `bl config agent` 配置 Qwen Code 时改用 `DASHSCOPE_API_KEY` 环境变量,不再使用 `BAILIAN_CLI_API_KEY`。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl config agent` 写入的配置现已与各 Agent 官方格式对齐:Claude Code 尊重 `CLAUDE_CONFIG_DIR` 并清理残留的 `ANTHROPIC_API_KEY`;Qwen Code 采用 v3 配置 schema 并正确写入凭证,避免被系统级 `OPENAI_API_KEY` 干扰;OpenCode 支持带注释和尾部逗号的 JSONC 配置文件;OpenClaw 会将主模型注册进模型白名单并补齐计费元数据;Hermes 改用官方扁平 `model.*` 结构;Codex 写入官方 `env_key` 并支持 `auth.json` 兜底。
|
||||
- `bl config agent` 写入配置时现会保留用户已有配置:合并而非覆盖,避免重复添加 provider 条目,并保留用户自定义的显示名。
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### 变更
|
||||
|
||||
- MCP 工具或 WebSearch 因对应服务未开通而不可用时,CLI 现在会提供开通指引和市场直达链接;对于使用旧版 SSE 连接的 WebSearch,还会提示重新开通以切换至 Streamable HTTP。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复文本对话与 API Key 登录校验因传递不受支持的 `enable_thinking` 参数值而产生的兼容性错误。文本对话仅在用户明确开启思考模式时传递该参数,登录校验则改用兼容模型且不再传递该参数。
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl image edit` 新增 `--function` 参数,支持为万相图片编辑模型(如 `wanx2.1-imageedit`)指定编辑功能。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复部分图片模型在图片生成与编辑时的调用失败和尺寸参数错误,并完善 Qwen-Image、Wan/Wanx、Z-Image 系列及 `wanx-v1` 日期版本的兼容性。
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl managed-agent`** —— 通过统一 CLI 声明式管理 Managed Agent 基础设施;百炼 Provider 对接 AgentStudio,并支持 Claude、Qoder 和 Ark:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` —— 基于 `agents.yaml` 初始化、校验、预览和执行资源变更,以及销毁已托管资源。
|
||||
- `state list` / `state show` / `state rm` / `state import` —— 查看和管理本地资源状态,包括纳管已有远端资源或仅解除本地跟踪。
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` —— 完整的会话生命周期操作,支持流式响应和结构化的 `--output json` 输出。
|
||||
- `skill-list` —— 浏览自定义与官方 Skill;使用 `--source all` 可一次返回两个来源。
|
||||
|
||||
### 变更
|
||||
|
||||
- 模型 Base URL 现在统一仅保留 URL Origin;传入的路径、查询参数和 Fragment 不再参与后续 API 请求路径拼接。
|
||||
|
||||
### 修复
|
||||
|
||||
- 安装指南不再推荐已移除的 `--non-interactive`,改为说明显式传入必填参数,并使用 `--output json` 或 `NO_COLOR=1` 适配非交互环境。
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
- 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
|
||||
|
||||
### 修复
|
||||
|
||||
+2
-1
@@ -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
@@ -6,8 +6,9 @@
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 参与本仓库开发:Node.js ≥ 22.12
|
||||
- 仅安装/使用已发布的 CLI:Node.js >= 18.17
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 跑 e2e 需要一个百炼 API Key
|
||||
|
||||
## 仓库结构
|
||||
|
||||
+17
-9
@@ -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`,勿在对话中向用户解释底层命名。
|
||||
@@ -89,20 +98,19 @@ npx skills add modelstudioai/cli --all -g
|
||||
### Agent 安全约束
|
||||
|
||||
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
|
||||
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
- CI / 非交互环境:显式传入必填参数并使用 `--output json` 获取机器可读结果;如需纯文本输出,设置 `NO_COLOR=1`。通过密钥管理或环境变量注入,勿在脚本中硬编码 Key。
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** 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
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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 |
|
||||
|
||||
## 更新日志
|
||||
|
||||
@@ -37,19 +37,39 @@ 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 使用的只读快照
|
||||
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
|
||||
|
||||
### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
|
||||
|
||||
`bl managed-agent *` 按调用链分两层:
|
||||
|
||||
- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言
|
||||
- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁,authStage 都经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
|
||||
|
||||
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:
|
||||
|
||||
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
|
||||
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
|
||||
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
|
||||
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
|
||||
5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
|
||||
|
||||
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. core 层(类型 + 解析)
|
||||
|
||||
@@ -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 输出 → 更新对应断言
|
||||
|
||||
@@ -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` 时,需要在允许本地进程和端口的环境中复跑。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 安装文档变更
|
||||
|
||||
## 触发条件
|
||||
|
||||
- 修改根目录 `INSTALL.md` 的安装、鉴权或验证流程
|
||||
- 修改发布包 Node.js 要求、全局 flag 或安装文档引用的命令
|
||||
- 同步或发布 `https://bailian.aliyun.com/cli/install.md`
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. CLI 契约
|
||||
|
||||
- [ ] `INSTALL.md` 中的 `bl` 命令路径存在于 `packages/cli/src/commands.ts`
|
||||
- [ ] 示例 flag 属于 `GLOBAL_FLAGS`、命令鉴权域 flag 或命令自身 `flags`
|
||||
- [ ] Node.js 用户安装要求与 `packages/cli/package.json` 的 `engines.node` 一致,不使用根 `package.json` 的开发环境要求
|
||||
- [ ] 鉴权流程与 `packages/commands/src/commands/auth/` 的实际校验、保存和 Profile 激活行为一致
|
||||
|
||||
### B. 静态副本
|
||||
|
||||
- [ ] 将 `INSTALL.md` 同步到 `bailian-cli-static-resources/public/install.txt`
|
||||
- [ ] 使用 `cmp -s` 确认两份文档逐字节一致
|
||||
- [ ] 静态资源仓库单独创建分支、提交和发布,不把跨仓库改动遗漏在 CLI PR 之外
|
||||
|
||||
### C. 线上验证
|
||||
|
||||
- [ ] 发布后读取 `https://bailian.aliyun.com/cli/install.md`,确认内容来自最新静态副本
|
||||
- [ ] 带随机 query 参数复查,区分 CDN 缓存与源站未更新
|
||||
- [ ] 验证线上文档中的安装命令、Node.js 要求和配置验证段落,不只检查页面可访问
|
||||
|
||||
## 完成后自查
|
||||
|
||||
```sh
|
||||
pnpm -F bailian-cli test -- tests/install-doc.test.ts
|
||||
cmp -s INSTALL.md ../bailian-cli-static-resources/public/install.txt
|
||||
curl -L -s "https://bailian.aliyun.com/cli/install.md?verify=$(date +%s)"
|
||||
```
|
||||
|
||||
## 常见漏点
|
||||
|
||||
- `--non-interactive` 已从 CLI 移除,但旧安装文档和静态副本仍把它当作全局 flag
|
||||
- 根 `package.json` 是开发工具链 Node.js 要求;用户安装要求以 `packages/cli/package.json` 为准
|
||||
- 静态仓库文件名是 `public/install.txt`,线上稳定地址是 `/cli/install.md`;只更新其中一侧不会自动证明发布成功
|
||||
@@ -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
@@ -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 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 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 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
|
||||
@@ -19,6 +19,9 @@ 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
|
||||
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
|
||||
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
|
||||
@@ -2,4 +2,7 @@ node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
outputs/
|
||||
outputs/
|
||||
# agents
|
||||
agents.state.json
|
||||
.env
|
||||
|
||||
+19
-2
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** 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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](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 |
|
||||
|
||||
## 更新日志
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.8.3",
|
||||
"version": "1.12.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -69,6 +69,6 @@
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -87,6 +93,23 @@ import {
|
||||
skillUpdate,
|
||||
skillRemove,
|
||||
skillList,
|
||||
managedAgentInit,
|
||||
managedAgentValidate,
|
||||
managedAgentPlan,
|
||||
managedAgentApply,
|
||||
managedAgentDestroy,
|
||||
managedAgentStateList,
|
||||
managedAgentStateShow,
|
||||
managedAgentStateRm,
|
||||
managedAgentStateImport,
|
||||
managedAgentSessionCreate,
|
||||
managedAgentSessionList,
|
||||
managedAgentSessionGet,
|
||||
managedAgentSessionDelete,
|
||||
managedAgentSessionRun,
|
||||
managedAgentSessionSend,
|
||||
managedAgentSessionEvents,
|
||||
managedAgentSkillList,
|
||||
} from "bailian-cli-commands";
|
||||
|
||||
// Full bailian-cli product: every command, exposed under the `bl` binary.
|
||||
@@ -98,6 +121,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,
|
||||
@@ -110,6 +134,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,
|
||||
@@ -174,6 +202,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,
|
||||
@@ -182,4 +211,21 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"skill update": skillUpdate,
|
||||
"skill remove": skillRemove,
|
||||
"skill list": skillList,
|
||||
"managed-agent init": managedAgentInit,
|
||||
"managed-agent validate": managedAgentValidate,
|
||||
"managed-agent plan": managedAgentPlan,
|
||||
"managed-agent apply": managedAgentApply,
|
||||
"managed-agent destroy": managedAgentDestroy,
|
||||
"managed-agent state list": managedAgentStateList,
|
||||
"managed-agent state show": managedAgentStateShow,
|
||||
"managed-agent state rm": managedAgentStateRm,
|
||||
"managed-agent state import": managedAgentStateImport,
|
||||
"managed-agent session create": managedAgentSessionCreate,
|
||||
"managed-agent session list": managedAgentSessionList,
|
||||
"managed-agent session get": managedAgentSessionGet,
|
||||
"managed-agent session delete": managedAgentSessionDelete,
|
||||
"managed-agent session run": managedAgentSessionRun,
|
||||
"managed-agent session send": managedAgentSessionSend,
|
||||
"managed-agent session events": managedAgentSessionEvents,
|
||||
"managed-agent skill-list": managedAgentSkillList,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { credentialFlagDefs, GLOBAL_FLAGS, type AnyCommand } from "bailian-cli-core";
|
||||
import { monorepoRoot } from "e2e/monorepo-root";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { commands } from "../src/commands.ts";
|
||||
|
||||
const repositoryRoot = monorepoRoot();
|
||||
const installGuide = readFileSync(join(repositoryRoot, "INSTALL.md"), "utf8");
|
||||
const cliPackage = JSON.parse(
|
||||
readFileSync(join(repositoryRoot, "packages/cli/package.json"), "utf8"),
|
||||
) as {
|
||||
engines?: { node?: string };
|
||||
};
|
||||
|
||||
function toFlagName(key: string): string {
|
||||
return `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
||||
}
|
||||
|
||||
function findDocumentedCommand(snippet: string): {
|
||||
commandPath?: string;
|
||||
command?: AnyCommand;
|
||||
} {
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
const commandPath = Object.keys(commands)
|
||||
.sort((leftPath, rightPath) => rightPath.length - leftPath.length)
|
||||
.find((candidatePath) => {
|
||||
return argumentText === candidatePath || argumentText.startsWith(`${candidatePath} `);
|
||||
});
|
||||
|
||||
return commandPath ? { commandPath, command: commands[commandPath] } : {};
|
||||
}
|
||||
|
||||
function documentedCommandSnippets(): string[] {
|
||||
const fencedCommands = installGuide
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("bl "));
|
||||
const inlineCommands = Array.from(installGuide.matchAll(/`(bl [^`\n]+)`/g), (match) => match[1]);
|
||||
return [...new Set([...fencedCommands, ...inlineCommands])];
|
||||
}
|
||||
|
||||
describe("INSTALL.md", () => {
|
||||
test("发布包 Node.js 要求与安装文档一致", () => {
|
||||
const nodeEngine = cliPackage.engines?.node;
|
||||
expect(nodeEngine).toMatch(/^>=\d+\.\d+\.\d+$/);
|
||||
expect(installGuide).toContain(`要求 **≥ ${nodeEngine?.slice(2)}**`);
|
||||
});
|
||||
|
||||
test("示例只使用当前命令支持的 flags", () => {
|
||||
for (const snippet of documentedCommandSnippets()) {
|
||||
const { commandPath, command } = findDocumentedCommand(snippet);
|
||||
const argumentText = snippet.slice("bl ".length).trim();
|
||||
|
||||
if (!commandPath || !command) {
|
||||
expect(argumentText, `INSTALL.md 中存在未知命令:${snippet}`).toMatch(/^--/);
|
||||
}
|
||||
|
||||
const supportedFlags = {
|
||||
...GLOBAL_FLAGS,
|
||||
...(command ? credentialFlagDefs(command) : {}),
|
||||
...command?.flags,
|
||||
};
|
||||
const supportedFlagNames = new Set(Object.keys(supportedFlags).map(toFlagName));
|
||||
const usedFlagNames = Array.from(snippet.matchAll(/--[a-z0-9-]+/g), (match) => match[0]);
|
||||
const unsupportedFlagNames = usedFlagNames.filter(
|
||||
(flagName) => !supportedFlagNames.has(flagName),
|
||||
);
|
||||
|
||||
expect(unsupportedFlagNames, `INSTALL.md 命令使用了未声明的 flag:${snippet}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.8.3",
|
||||
"version": "1.12.0",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
@@ -40,10 +40,12 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openagentpack/sdk": "0.3.1",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"smol-toml": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,6 +56,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,99 @@
|
||||
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 = "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,
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,15 @@ 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 +58,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 +97,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 +117,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 +146,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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,153 @@
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Decoder for the obfuscated API key ("o1_…") produced by the Model Studio web
|
||||
* console. Ported verbatim from the frontend `encodeTokenPlanKey` counterpart:
|
||||
* token = "o1_" + salt(6) + feistel-obfuscated payload + crc32 checksum(6),
|
||||
* all over a 65-character alphabet. Pure logic, no dependencies; the CLI only
|
||||
* ever needs the decode direction.
|
||||
*/
|
||||
|
||||
const TOKEN_PREFIX = "o1_";
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
|
||||
const ALPHABET_SIZE = ALPHABET.length;
|
||||
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
|
||||
const KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const SALT_LENGTH = 6;
|
||||
const CHECKSUM_LENGTH = 6;
|
||||
const FEISTEL_ROUNDS = 8;
|
||||
|
||||
function invalidCredential(): BailianError {
|
||||
return new BailianError(
|
||||
"Invalid obfuscated API key.",
|
||||
ExitCode.USAGE,
|
||||
'--key expects the obfuscated key copied from the web console (starts with "o1_").',
|
||||
);
|
||||
}
|
||||
|
||||
function toDigits(value: string): number[] {
|
||||
const digits: number[] = [];
|
||||
for (const character of value) {
|
||||
const digit = ALPHABET_INDEX.get(character);
|
||||
if (digit === undefined) throw invalidCredential();
|
||||
digits.push(digit);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function fromDigits(digits: number[]): string {
|
||||
return digits.map((digit) => ALPHABET[digit]).join("");
|
||||
}
|
||||
|
||||
function mixState(state: number, value: number): number {
|
||||
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
function nextState(state: number): number {
|
||||
let next = state >>> 0;
|
||||
next ^= next << 13;
|
||||
next ^= next >>> 17;
|
||||
next ^= next << 5;
|
||||
return next >>> 0;
|
||||
}
|
||||
|
||||
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
|
||||
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
|
||||
|
||||
state = mixState(state, right.length);
|
||||
state = mixState(state, length);
|
||||
for (const character of salt) {
|
||||
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
|
||||
}
|
||||
for (const digit of right) {
|
||||
state = mixState(state, digit + 1);
|
||||
}
|
||||
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 0x85ebca6b) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 0xc2b2ae35) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = state >>> 0 || 0x6d2b79f5;
|
||||
|
||||
const mask: number[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
|
||||
state = nextState(state);
|
||||
mask.push(state % ALPHABET_SIZE);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function deobfuscatePayload(payload: string, salt: string): string {
|
||||
const digits = toDigits(payload);
|
||||
const midpoint = Math.floor(digits.length / 2);
|
||||
let left = digits.slice(0, midpoint);
|
||||
let right = digits.slice(midpoint);
|
||||
|
||||
for (let round = FEISTEL_ROUNDS - 1; round >= 0; round -= 1) {
|
||||
const previousRight = left;
|
||||
const mask = createRoundMask(previousRight, salt, round, right.length);
|
||||
const previousLeft = right.map(
|
||||
(digit, index) => (digit - mask[index] + ALPHABET_SIZE) % ALPHABET_SIZE,
|
||||
);
|
||||
left = previousLeft;
|
||||
right = previousRight;
|
||||
}
|
||||
|
||||
return fromDigits([...left, ...right]);
|
||||
}
|
||||
|
||||
function crc32(value: string): number {
|
||||
let checksum = 0xffffffff;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
checksum ^= value.charCodeAt(index);
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
const mask = -(checksum & 1);
|
||||
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
|
||||
}
|
||||
}
|
||||
return (checksum ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function encodeBase65Number(value: number, length: number): string {
|
||||
let remaining = value >>> 0;
|
||||
const encoded = Array<string>(length).fill(ALPHABET[0]);
|
||||
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
|
||||
remaining = Math.floor(remaining / ALPHABET_SIZE);
|
||||
}
|
||||
if (remaining !== 0) throw invalidCredential();
|
||||
return encoded.join("");
|
||||
}
|
||||
|
||||
function validateSalt(salt: string): void {
|
||||
if (salt.length !== SALT_LENGTH || !KEY_PATTERN.test(salt)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an "o1_…" obfuscated token back into the plain API key. */
|
||||
export function decodeTokenPlanKey(token: string): string {
|
||||
const minimumLength = TOKEN_PREFIX.length + SALT_LENGTH + CHECKSUM_LENGTH + 1;
|
||||
if (token.length < minimumLength || !token.startsWith(TOKEN_PREFIX)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
|
||||
const body = token.slice(TOKEN_PREFIX.length);
|
||||
if (!KEY_PATTERN.test(body)) throw invalidCredential();
|
||||
|
||||
const salt = body.slice(0, SALT_LENGTH);
|
||||
const payload = body.slice(SALT_LENGTH, -CHECKSUM_LENGTH);
|
||||
const checksum = body.slice(-CHECKSUM_LENGTH);
|
||||
validateSalt(salt);
|
||||
if (!payload) throw invalidCredential();
|
||||
|
||||
const apiKey = deobfuscatePayload(payload, salt);
|
||||
if (!KEY_PATTERN.test(apiKey)) throw invalidCredential();
|
||||
|
||||
const expectedChecksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
|
||||
if (checksum !== expectedChecksum) throw invalidCredential();
|
||||
return apiKey;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
import { decodeTokenPlanKey } from "./decode-key.ts";
|
||||
import { resolveRegionBaseUrl } from "./writers/utils.ts";
|
||||
|
||||
const FLAGS = {
|
||||
agent: {
|
||||
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",
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
valueHint: "<region>",
|
||||
description:
|
||||
"Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "API key",
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
valueHint: "<encoded>",
|
||||
description:
|
||||
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
contextWindow: {
|
||||
type: "number",
|
||||
valueHint: "<tokens>",
|
||||
description: "OpenClaw only: model context window in tokens (default: 256000)",
|
||||
},
|
||||
wireApi: {
|
||||
type: "string",
|
||||
valueHint: "<api>",
|
||||
description:
|
||||
'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0',
|
||||
choices: ["chat", "responses"],
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Configure a coding agent to use DashScope API",
|
||||
auth: "none",
|
||||
usageArgs:
|
||||
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: [
|
||||
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
|
||||
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
],
|
||||
validate(flags) {
|
||||
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
|
||||
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
|
||||
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
|
||||
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { model, contextWindow, wireApi } = flags;
|
||||
// --region is a Token Plan convenience: convert it into a base URL and use
|
||||
// it exactly as --base-url would be.
|
||||
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
|
||||
// --key carries the web console's obfuscated form; decode it up front so
|
||||
// even --dry-run validates the token.
|
||||
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// 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,
|
||||
contextWindow,
|
||||
wireApi,
|
||||
};
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
emitBare(`${agentDef.label} configured successfully.`);
|
||||
for (const path of summary.paths) emitBare(` Written: ${path}`);
|
||||
emitBare(` ${summary.nextStep}`);
|
||||
for (const warning of summary.warnings ?? []) {
|
||||
process.stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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,68 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
resolveClaudeCodeBaseUrl,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** Fill a tier/default model env only when the user has not set it yet. */
|
||||
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
|
||||
const current = env[key];
|
||||
if (current === undefined || current.trim() === "") {
|
||||
env[key] = model;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
||||
const settingsPath = join(configDir, "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
const warnings: string[] = [];
|
||||
|
||||
const resolved = resolveClaudeCodeBaseUrl(baseUrl);
|
||||
if (resolved.rewrittenFrom) {
|
||||
warnings.push(
|
||||
`Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` +
|
||||
`(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`,
|
||||
);
|
||||
}
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
// the Anthropic-compatible endpoint; primary model always updates, while
|
||||
// tier/subagent defaults are filled only when absent so existing setups
|
||||
// (e.g. Token Plan Haiku/Subagent splits) are not wiped.
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = resolved.url;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
// AUTH_TOKEN and API_KEY are mutually exclusive credential fields — drop a
|
||||
// stale ANTHROPIC_API_KEY so it cannot shadow the token we just wrote.
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model);
|
||||
settings.env = env;
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
// .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.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,72 @@
|
||||
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, wireApi: wireApiParam }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
const warnings: string[] = [];
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
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;
|
||||
|
||||
// wire_api — current Codex releases only load `wire_api = "responses"`
|
||||
// ("chat" is rejected at config load, see openai/codex discussion #7782).
|
||||
// "chat" remains an explicit opt-in for users pinned to legacy Codex
|
||||
// <= 0.80.0 (the Model Studio path for models without Responses support).
|
||||
const wireApi = wireApiParam === "chat" ? "chat" : "responses";
|
||||
if (wireApi === "chat") {
|
||||
warnings.push(
|
||||
'Current Codex releases refuse to load `wire_api = "chat"`; ' +
|
||||
"only use --wire-api chat with legacy Codex <= 0.80.0 " +
|
||||
"(e.g. `npm install -g @openai/codex@0.80.0`).",
|
||||
);
|
||||
}
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
providers[PROVIDER_KEY] = {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
// env_key is the official-doc credential mechanism: Codex resolves the
|
||||
// key from the OPENAI_API_KEY env var, falling back to auth.json below.
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: wireApi,
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset.
|
||||
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.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,43 @@
|
||||
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";
|
||||
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Official Model Studio doc shape: a single flat `model` block holding the
|
||||
// active endpoint + credentials. `api_mode: anthropic_messages` is required
|
||||
// for /apps/anthropic endpoints; for the OpenAI-compatible endpoint the
|
||||
// doc says to omit api_mode entirely (chat completions is the default).
|
||||
const block: Record<string, unknown> = {
|
||||
default: model,
|
||||
provider: "custom",
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
};
|
||||
if (isAnthropicEndpoint(baseUrl)) block.api_mode = "anthropic_messages";
|
||||
config.model = block;
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: 'Run `hermes chat -q "hello"` to verify.',
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
// Safe default when --context-window is not given: most Model Studio models
|
||||
// offer ≥256K context; users can raise it per model via the flag.
|
||||
const DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
|
||||
const PROVIDER_ID = "bailian-cli";
|
||||
|
||||
function readPrimary(defaults: Record<string, unknown>): string | undefined {
|
||||
const model = defaults.model;
|
||||
if (!model || typeof model !== "object") return undefined;
|
||||
const primary = (model as Record<string, unknown>).primary;
|
||||
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model, contextWindow }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const warnings: string[] = [];
|
||||
const modelRef = `${PROVIDER_ID}/${model}`;
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
// models.providers["bailian-cli"] — upsert without removing other providers
|
||||
// (e.g. an existing working bailian-token-plan setup).
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers[PROVIDER_ID] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults — register the model in the allow-list. Only set primary
|
||||
// when unset, or when primary already points at bailian-cli (reconfigure).
|
||||
// Never steal primary away from another provider such as bailian-token-plan.
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
|
||||
allowedModels[modelRef] = allowedModels[modelRef] ?? {};
|
||||
defaults.models = allowedModels;
|
||||
|
||||
const existingPrimary = readPrimary(defaults);
|
||||
if (!existingPrimary) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else {
|
||||
warnings.push(
|
||||
`Left existing primary model unchanged ("${existingPrimary}"). ` +
|
||||
`Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`,
|
||||
);
|
||||
}
|
||||
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
writeJsonAtomic(configPath, config);
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep:
|
||||
"Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
// opencode.json is JSONC — tolerate comments and trailing commas on read.
|
||||
backup(configPath);
|
||||
const config = readJsonc(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,119 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "DASHSCOPE_API_KEY";
|
||||
|
||||
function displayName(model: string): string {
|
||||
return `[Bailian] ${model}`;
|
||||
}
|
||||
|
||||
/** Entries we previously wrote, or still own via envKey / display brand. */
|
||||
function isBailianCliEntry(entry: Record<string, unknown>): boolean {
|
||||
if (entry.envKey === ENV_KEY) return true;
|
||||
const name = typeof entry.name === "string" ? entry.name : "";
|
||||
return name === "bailian-cli" || name.startsWith("[Bailian]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the env var name (`BAILIAN_CLI_API_KEY`) and the display
|
||||
* label (`[Bailian] …`); Qwen Code keys models by id (+ baseUrl), never by name.
|
||||
*
|
||||
* Qwen Code does not support duplicate model `id`s (only the first loads), so
|
||||
* we must never overwrite a pre-existing Token Plan / third-party entry that
|
||||
* shares the same id.
|
||||
*
|
||||
* Credentials are written to BOTH `env` (via the entry's `envKey`) and
|
||||
* `security.auth` — the resolver reads `security.auth.apiKey/baseUrl` as a
|
||||
* lower-priority layer, which stops a stray system `OPENAI_API_KEY` from being
|
||||
* picked up when the provider→envKey path does not resolve first. The active
|
||||
* `model` also carries its `baseUrl`, as Qwen Code requires to disambiguate
|
||||
* same-id providers.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".qwen", "settings.json");
|
||||
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
|
||||
const warnings: string[] = [];
|
||||
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// $version — Qwen Code v3 settings schema (official Model Studio doc shape).
|
||||
settings.$version = 3;
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
// Qwen Code treats settings.json `env` as lowest priority; a process/shell
|
||||
// value for the same key wins and can make the first launch fail.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
const processEnvValue = process.env[ENV_KEY];
|
||||
if (processEnvValue !== undefined && processEnvValue !== apiKey) {
|
||||
warnings.push(
|
||||
`Shell/environment ${ENV_KEY} is set and overrides settings.json. ` +
|
||||
`Unset it (e.g. \`unset ${ENV_KEY}\`) so the key written here takes effect.`,
|
||||
);
|
||||
}
|
||||
|
||||
// modelProviders[<protocol>] — upsert only bailian-cli-owned entries.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model);
|
||||
const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model);
|
||||
|
||||
if (owned) {
|
||||
owned.baseUrl = baseUrl;
|
||||
owned.envKey = ENV_KEY;
|
||||
const currentName = typeof owned.name === "string" ? owned.name.trim() : "";
|
||||
if (!currentName || currentName === "bailian-cli") owned.name = displayName(model);
|
||||
} else if (conflicting) {
|
||||
const existingName =
|
||||
typeof conflicting.name === "string" && conflicting.name.length > 0
|
||||
? conflicting.name
|
||||
: String(conflicting.id);
|
||||
warnings.push(
|
||||
`Model id "${model}" already exists as "${existingName}"; left unchanged ` +
|
||||
`(Qwen Code loads only the first entry per id). Remove or rename that ` +
|
||||
`entry if you want bailian-cli to own this model.`,
|
||||
);
|
||||
} else {
|
||||
entries.push({
|
||||
id: model,
|
||||
name: displayName(model),
|
||||
baseUrl,
|
||||
envKey: ENV_KEY,
|
||||
});
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol AND keep credentials as a fallback
|
||||
// layer (see the file-level note): without this, a stray system
|
||||
// OPENAI_API_KEY can win when the provider→envKey lookup does not resolve.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model. baseUrl MUST be written alongside name; Qwen Code
|
||||
// uses it to disambiguate same-id providers, and omitting it can misroute
|
||||
// to a different entry (and thus a different credential).
|
||||
settings.model = { name: model, baseUrl };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
return {
|
||||
paths: [settingsPath],
|
||||
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,215 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** OpenClaw model entry context window (tokens). */
|
||||
contextWindow?: number;
|
||||
/** Codex provider wire protocol: "responses" or "chat". */
|
||||
wireApi?: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
export interface WriteSummary {
|
||||
paths: string[];
|
||||
nextStep: string;
|
||||
/** Non-fatal issues the command should surface to the user. */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/** An agent configuration writer: a human label plus a `write` that applies it. */
|
||||
export interface AgentDef {
|
||||
label: string;
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip JSONC syntax (line / block comments and trailing commas) so the result
|
||||
* parses with `JSON.parse`. String contents are preserved verbatim.
|
||||
*/
|
||||
export function stripJsonc(text: string): string {
|
||||
// Pass 1 — drop comments (string contents preserved verbatim).
|
||||
let uncommented = "";
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (inString) {
|
||||
uncommented += char;
|
||||
if (char === "\\") {
|
||||
uncommented += next ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
while (index < text.length && text[index] !== "\n") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// Pass 2 — drop trailing commas (a comma whose next non-whitespace char
|
||||
// closes an object/array). Runs after comment removal so a trailing comment
|
||||
// cannot hide the closing bracket.
|
||||
let output = "";
|
||||
index = 0;
|
||||
inString = false;
|
||||
while (index < uncommented.length) {
|
||||
const char = uncommented[index];
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (char === "\\") {
|
||||
output += uncommented[index + 1] ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = true;
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1;
|
||||
if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */
|
||||
export function readJsonc(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code speaks Anthropic Messages only. Users often paste the OpenAI
|
||||
* compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise
|
||||
* fail with a clear USAGE error before writing a broken config.
|
||||
*/
|
||||
export function resolveClaudeCodeBaseUrl(baseUrl: string): {
|
||||
url: string;
|
||||
rewrittenFrom?: string;
|
||||
} {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
|
||||
if (isAnthropicEndpoint(trimmed)) {
|
||||
return { url: trimmed };
|
||||
}
|
||||
|
||||
if (trimmed.includes("/compatible-mode")) {
|
||||
const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic");
|
||||
return { url: rewritten, rewrittenFrom: baseUrl.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const host = parsed.hostname;
|
||||
const isDashScopeHost =
|
||||
host.includes("dashscope") ||
|
||||
host.includes("maas.aliyuncs.com") ||
|
||||
host.includes("token-plan");
|
||||
if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) {
|
||||
return {
|
||||
url: `${parsed.origin}/apps/anthropic`,
|
||||
rewrittenFrom: baseUrl.trim(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the USAGE error below.
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Model Studio region id into a Token Plan base URL, used in place of
|
||||
* --base-url. Produces the OpenAI-compatible endpoint; the claude-code writer
|
||||
* rewrites it to /apps/anthropic on its own, and the other writers consume the
|
||||
* compatible-mode URL directly.
|
||||
*/
|
||||
export function resolveRegionBaseUrl(region: string): string {
|
||||
const normalized = region.trim();
|
||||
if (!/^[a-z0-9-]+$/.test(normalized)) {
|
||||
throw new BailianError(
|
||||
`Invalid --region "${region}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a Model Studio region id, e.g. cn-beijing or ap-southeast-1.",
|
||||
);
|
||||
}
|
||||
return `https://token-plan.${normalized}.maas.aliyuncs.com/compatible-mode/v1`;
|
||||
}
|
||||
@@ -0,0 +1,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 ? "*" : ""}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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,10 +11,15 @@ 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,
|
||||
},
|
||||
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
|
||||
},
|
||||
exampleArgs: [
|
||||
"--key output --value json",
|
||||
@@ -70,47 +31,38 @@ 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>);
|
||||
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 & 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>
|
||||
`;
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
resolveOutputDir,
|
||||
@@ -20,8 +18,10 @@ import {
|
||||
BailianError,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageEditApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile } from "bailian-cli-runtime";
|
||||
@@ -31,12 +31,6 @@ import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
}
|
||||
|
||||
const EDIT_FLAGS = {
|
||||
image: {
|
||||
type: "array",
|
||||
@@ -71,6 +65,12 @@ const EDIT_FLAGS = {
|
||||
valueHint: "<text>",
|
||||
description: "Negative prompt to exclude unwanted content",
|
||||
},
|
||||
function: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description:
|
||||
"wanx*-imageedit function (default: description_edit). Examples: stylization_all, description_edit",
|
||||
},
|
||||
promptExtend: {
|
||||
type: "boolean",
|
||||
valueHint: "<bool>",
|
||||
@@ -98,7 +98,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 +107,9 @@ 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 "Place the subject on a table" --model wan2.5-i2i-preview',
|
||||
'--image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all',
|
||||
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -121,70 +124,138 @@ export default defineCommand({
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const useSync = isSyncModel(model);
|
||||
const route = resolveImageEditApi(model);
|
||||
|
||||
// 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,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
// Build content: all images first, then text prompt
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(u: string) => ({ image: u }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
size: resolveImageSize(flags.size, useSync),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size: resolveImageSize(flags.size, route.sizeProfile),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
let body: DashScopeImageRequest;
|
||||
if (route.inputStyle === "function-base-image") {
|
||||
const baseImageUrl = resolvedImages[0];
|
||||
if (!baseImageUrl) {
|
||||
throw new BailianError(
|
||||
"wanx*-imageedit requires at least one --image as base_image_url.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
function: flags.function || "description_edit",
|
||||
prompt,
|
||||
base_image_url: baseImageUrl,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else if (route.inputStyle === "prompt-images") {
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
images: resolvedImages,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else {
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(imageUrl: string) => ({ image: imageUrl }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Remove undefined parameters
|
||||
stripUndefined(body.parameters as Record<string, unknown>);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
let previewBody: DashScopeImageRequest = body;
|
||||
if ("messages" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
} else if ("images" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)),
|
||||
},
|
||||
};
|
||||
} else if ("base_image_url" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
base_image_url: redactDataUri(body.input.base_image_url),
|
||||
mask_image_url: body.input.mask_image_url
|
||||
? redactDataUri(body.input.mask_image_url)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
emitResult(
|
||||
{ request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -192,6 +263,7 @@ export default defineCommand({
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -199,15 +271,15 @@ async function handleSyncMode(
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({
|
||||
path: imageSyncPath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -221,6 +293,7 @@ async function handleSyncMode(
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -231,14 +304,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
if (flags.async) {
|
||||
emitResult({ task_ids: taskIds }, format);
|
||||
@@ -251,12 +324,12 @@ async function handleAsyncMode(
|
||||
url: client.url(taskPath(taskId)),
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -267,13 +340,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -303,8 +376,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
generateFilename,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageGenerateApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
} from "bailian-cli-core";
|
||||
@@ -31,13 +30,6 @@ 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"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
}
|
||||
|
||||
const GENERATE_FLAGS = {
|
||||
prompt: { type: "string", valueHint: "<text>", description: "Image description", required: true },
|
||||
model: {
|
||||
@@ -104,6 +96,8 @@ export default defineCommand({
|
||||
'--prompt "Logo" --watermark false',
|
||||
'--prompt "An alien in the space" --watermark false',
|
||||
'--prompt "sunset" --model wan2.6-t2i --async --quiet',
|
||||
'--prompt "plush doll" --model z-image-turbo --size 1024*1024',
|
||||
'--prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024',
|
||||
'--prompt "Pro quality" --model qwen-image-2.0-pro',
|
||||
'--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
|
||||
],
|
||||
@@ -112,73 +106,90 @@ export default defineCommand({
|
||||
const prompt = flags.prompt;
|
||||
|
||||
const model = flags.model || settings.defaultImageModel || "qwen-image-2.0";
|
||||
const useSync = isSyncModel(model);
|
||||
const defaultSize = useSync ? "1:1" : "1:1";
|
||||
const route = resolveImageGenerateApi(model);
|
||||
const defaultSize = "1:1";
|
||||
const sizeInput = flags.size || defaultSize;
|
||||
const size = resolveImageSize(sizeInput, useSync);
|
||||
const size = resolveImageSize(sizeInput, route.sizeProfile);
|
||||
const n = flags.n ?? 1;
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
const body: DashScopeImageRequest =
|
||||
route.inputStyle === "prompt"
|
||||
? {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
emitResult(
|
||||
{ request: body, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`);
|
||||
}
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Sync mode: qwen-image-2.0 series ----
|
||||
// ---- Sync mode: qwen-image / wan2.7-image / z-image ----
|
||||
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
concurrent: number,
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path: imageSyncPath(), method: "POST", body }),
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path, method: "POST", body }),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -189,12 +200,12 @@ async function handleSyncMode(
|
||||
await saveImages(imageUrls, flags, settings, format);
|
||||
}
|
||||
|
||||
// ---- Async mode: wan2.x / qwen-image-plus ----
|
||||
// ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ----
|
||||
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
@@ -205,14 +216,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
// --async: return all task IDs immediately
|
||||
if (flags.async) {
|
||||
@@ -229,12 +240,12 @@ async function handleAsyncMode(
|
||||
url: pollUrl,
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -245,13 +256,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -293,8 +304,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ResourceAddress } from "@openagentpack/sdk";
|
||||
|
||||
/** Full state address: provider.type.name */
|
||||
export function formatResourceAddress(address: ResourceAddress): string {
|
||||
return `${address.provider}.${address.type}.${address.name}`;
|
||||
}
|
||||
|
||||
/** CLI display short label: type.name (provider) */
|
||||
export function formatResourceLabel(address: ResourceAddress): string {
|
||||
return `${address.type}.${address.name} (${address.provider})`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
createProjectRuntime,
|
||||
type LoadedProjectConfig,
|
||||
type ProjectRuntimeContext,
|
||||
resolveProjectConfig,
|
||||
UserError,
|
||||
} from "@openagentpack/sdk";
|
||||
import {
|
||||
assertProviderCredentials,
|
||||
type CredentialHost,
|
||||
injectProviderCredentials,
|
||||
normalizeInterpolatedProviderBlocks,
|
||||
prepareProviderEnv,
|
||||
scrubCredentialEnv,
|
||||
} from "./credentials.ts";
|
||||
import { loadFileState } from "./file-state-manager.ts";
|
||||
import { type HostContext, installSdkTransport } from "./transport.ts";
|
||||
|
||||
export { CREDENTIALS_NOTE, OFFLINE_NOTE } from "./credentials.ts";
|
||||
|
||||
/**
|
||||
* Whether this run requires provider keys:
|
||||
* - "all" (default) — online command: every provider declared in agents.yaml
|
||||
* must have a non-empty key after injection
|
||||
* - "none" — offline command (local config/state only), skip the check
|
||||
*/
|
||||
export type CredentialScope = "all" | "none";
|
||||
|
||||
interface AgentConfigOptions {
|
||||
resolveEnv?: boolean;
|
||||
projectName?: string;
|
||||
statePath?: string;
|
||||
credentials?: CredentialScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve agents.yaml with credentials injected the bl way and scrubbed from the
|
||||
* environment — the shared credential spine for every SDK-engine command:
|
||||
* 1. prepare env (SDK bootstrap for non-bailian + placeholders so interpolation
|
||||
* never throws on a value we're about to supply/reject)
|
||||
* 2. resolve + interpolate the config
|
||||
* 3. override the bailian block with the CLI auth chain's credential (in-memory)
|
||||
* 4. scrub all credential vars from process.env (real values now live only in
|
||||
* the config object → provider adapters, never the environment)
|
||||
* 5. fail with a CLI-authoritative AUTH error if any provider's key is empty
|
||||
* (offline commands pass `credentials: "none"` to skip the check)
|
||||
*/
|
||||
export async function resolveAgentProjectConfig(
|
||||
host: CredentialHost,
|
||||
filePath: string,
|
||||
options: AgentConfigOptions = {},
|
||||
): Promise<LoadedProjectConfig> {
|
||||
prepareProviderEnv();
|
||||
const resolved = await resolveProjectConfig(filePath, options);
|
||||
normalizeInterpolatedProviderBlocks(resolved.config.providers);
|
||||
injectProviderCredentials(resolved.config.providers, host);
|
||||
scrubCredentialEnv();
|
||||
if ((options.credentials ?? "all") !== "none") {
|
||||
assertProviderCredentials(resolved.config.providers);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full ProjectRuntimeContext from a config file path — the standard
|
||||
* entry point for agent commands that need the SDK engine. Mirrors OpenAgentPack
|
||||
* CLI's buildCliRuntime: resolve config → load local state → assemble runtime.
|
||||
* Takes the host context first so every SDK-engine command wires the
|
||||
* instrumented transport (UA / tracking headers / verbose) and the bl-resolved,
|
||||
* in-memory-injected credential ({@link resolveAgentProjectConfig}) by construction.
|
||||
*/
|
||||
export async function buildAgentRuntime(
|
||||
host: HostContext & CredentialHost,
|
||||
filePath: string,
|
||||
options: AgentConfigOptions = {},
|
||||
): Promise<ProjectRuntimeContext & { configPath: string }> {
|
||||
installSdkTransport(host);
|
||||
const { config, configPath, projectName } = await resolveAgentProjectConfig(
|
||||
host,
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
const state = await loadFileState(configPath, options.statePath, projectName);
|
||||
const ctx = createProjectRuntime({
|
||||
projectName,
|
||||
config,
|
||||
state,
|
||||
configPath,
|
||||
providers: config.providers,
|
||||
});
|
||||
return { ...ctx, configPath };
|
||||
}
|
||||
|
||||
/** Ensure a user-supplied --provider value is actually configured in agents.yaml. */
|
||||
export function assertProviderConfigured(
|
||||
ctx: ProjectRuntimeContext,
|
||||
provider: string | undefined,
|
||||
): void {
|
||||
if (!provider || provider === "all") return;
|
||||
if (ctx.providers.has(provider)) return;
|
||||
const available = Array.from(ctx.providers.keys()).join(", ") || "none";
|
||||
throw new UserError(
|
||||
`Provider '${provider}' is not configured. Available providers: ${available}.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Redirect `console.log` / `console.info` to stderr while `fn` runs.
|
||||
*
|
||||
* The OpenAgentPack SDK's provider adapters emit progress/debug logging via
|
||||
* `console.log` (e.g. `[skill-upload]`), which would corrupt bl's stdout data
|
||||
* channel in `--output json` mode. Wrapping SDK calls that may log keeps stdout
|
||||
* a clean data channel. Restores the originals on completion.
|
||||
*/
|
||||
export async function withStdoutProtected<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const originalLog = console.log;
|
||||
const originalInfo = console.info;
|
||||
const toStderr = (...args: unknown[]): void => {
|
||||
process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`);
|
||||
};
|
||||
console.log = toStderr;
|
||||
console.info = toStderr;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.info = originalInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AGENTS_PROVIDER_FIELDS, bootstrapRuntimeCredentialsSync } from "@openagentpack/sdk";
|
||||
import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* AgentStudio API path the SDK's BailianClient serves resources under. bl's
|
||||
* `base_url` is the bare model-service origin (e.g. https://dashscope.aliyuncs.com);
|
||||
* the SDK appends resource paths onto the bailian provider's `base_url` verbatim,
|
||||
* so the agent path must carry this suffix. See OpenAgentPack BailianClient.
|
||||
*/
|
||||
const AGENTSTUDIO_API_PATH = "/api/v1/agentstudio";
|
||||
|
||||
/**
|
||||
* Every env var the SDK recognizes as provider credential material (primary keys
|
||||
* from the SDK's own field map) plus bl-side interpolation aliases and bailian's
|
||||
* endpoint var (not part of AGENTS_PROVIDER_FIELDS). These are the only vars the
|
||||
* pipeline placeholders (to keep interpolation from throwing) and scrubs (so no
|
||||
* real credential persists in the environment).
|
||||
*/
|
||||
const CREDENTIAL_ENV_KEYS = [
|
||||
...new Set([
|
||||
...Object.values(AGENTS_PROVIDER_FIELDS).flatMap((fields) => fields.map((field) => field.key)),
|
||||
"BAILIAN_API_KEY",
|
||||
"BAILIAN_BASE_URL",
|
||||
"CLAUDE_API_KEY",
|
||||
"QODER_API_KEY",
|
||||
]),
|
||||
];
|
||||
|
||||
/** How to obtain each provider's key, surfaced in the CLI's own AUTH error when it is missing. */
|
||||
const CREDENTIAL_HINTS: Record<string, string> = {
|
||||
bailian: "Run `bl auth login --api-key <key>`, pass --api-key, or set DASHSCOPE_API_KEY.",
|
||||
claude: "Set ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in your shell or .env.",
|
||||
ark: "Set ARK_API_KEY in your shell or .env.",
|
||||
qoder: "Set QODER_PAT (or QODER_API_KEY) in your shell or .env.",
|
||||
};
|
||||
|
||||
/** The slice of CommandContext the credential pipeline needs: authStage-resolved client + settings. */
|
||||
export interface CredentialHost {
|
||||
client: Client;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `--help` note documenting where agent commands get provider
|
||||
* credentials. Bailian goes through bl's own auth chain (commands declare
|
||||
* `auth: "apiKey"`); other providers come from env. Either way the resolved
|
||||
* credential is injected into the SDK in-memory and scrubbed from the
|
||||
* environment. Attach to every command that loads agents.yaml. `bl` prefix is
|
||||
* safe: agent commands ship on `bl` only.
|
||||
*/
|
||||
export const CREDENTIALS_NOTE = [
|
||||
"Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).",
|
||||
"Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.",
|
||||
"Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared `--help` note for commands that never talk to a provider: they load
|
||||
* agents.yaml / local state only, so no login or provider key is required.
|
||||
*/
|
||||
export const OFFLINE_NOTE = [
|
||||
"Runs fully offline against local files: no login or provider credentials required.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the SDK's env-based credential sources (`.env`, `~/.agents/config.json`)
|
||||
* for non-bailian providers, then placeholder every credential var that is still
|
||||
* unset with "" so agents.yaml `${VAR}` interpolation never throws on a value the
|
||||
* pipeline is about to supply (bailian) or authoritatively reject ({@link
|
||||
* assertProviderCredentials}). Runs every call (no I/O cache) so a scrubbed
|
||||
* environment is repopulated if the same process resolves more than one config.
|
||||
*/
|
||||
export function prepareProviderEnv(): void {
|
||||
bootstrapRuntimeCredentialsSync();
|
||||
for (const key of CREDENTIAL_ENV_KEYS) {
|
||||
if (process.env[key] === undefined) process.env[key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the bailian provider block with bl's authStage-resolved credential, so
|
||||
* the bailian API key is authoritatively the CLI auth chain's — never a config
|
||||
* file bare-read or a stale env value. `api_key` is replaced unconditionally
|
||||
* when a credential resolved; `base_url` / `workspace_id` are filled only when
|
||||
* the block references them and the interpolated value is empty (a literal in
|
||||
* agents.yaml is respected).
|
||||
*
|
||||
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
|
||||
* paths onto it verbatim; a value already ending in the suffix is left as-is.
|
||||
* It is filled even without a credential — `client.baseUrl` is readable
|
||||
* credential-less (defaults to the CLI's model-domain base URL) — so offline
|
||||
* commands (which skip the credential assert) still satisfy the SDK's
|
||||
* "workspace_id or base_url" schema. With no credential the `api_key` is left
|
||||
* untouched: online commands reject it via {@link assertProviderCredentials}.
|
||||
*/
|
||||
export function injectProviderCredentials(
|
||||
providers: Record<string, unknown>,
|
||||
host: CredentialHost,
|
||||
): void {
|
||||
const bailian = providers.bailian;
|
||||
if (!bailian || typeof bailian !== "object") return;
|
||||
const block = bailian as Record<string, unknown>;
|
||||
|
||||
const cred = host.client.exportApiCredential();
|
||||
if (cred) block.api_key = cred.token;
|
||||
if ("base_url" in block && !block.base_url) {
|
||||
// Defensive normalization: the auth chain already normalizes base_url to
|
||||
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
|
||||
const origin = host.client.baseUrl.replace(/\/+$/, "");
|
||||
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
|
||||
? origin
|
||||
: `${origin}${AGENTSTUDIO_API_PATH}`;
|
||||
}
|
||||
if ("workspace_id" in block && !block.workspace_id && host.settings.workspaceId) {
|
||||
block.workspace_id = host.settings.workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every credential var from `process.env` after interpolation has run and
|
||||
* bailian has been overridden in-memory. From here on the real credentials live
|
||||
* only in the config object (and, after `createProjectRuntime`, in each provider
|
||||
* adapter instance) — nothing persists in the environment for the process
|
||||
* lifetime or any child process.
|
||||
*/
|
||||
export function scrubCredentialEnv(): void {
|
||||
for (const key of CREDENTIAL_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK interpolates `${VAR}` into the raw YAML text, so an empty env var
|
||||
* leaves `api_key:` with nothing after it — YAML parses that as null. Normalize
|
||||
* every null provider field back to "" so the pipeline stays uniform: for
|
||||
* online commands an empty api_key is caught by {@link
|
||||
* assertProviderCredentials}; for offline commands (which skip the assert) the
|
||||
* blocks still satisfy the SDK's string schemas instead of failing zod with
|
||||
* "received null" before the run even starts.
|
||||
*/
|
||||
export function normalizeInterpolatedProviderBlocks(providers: Record<string, unknown>): void {
|
||||
for (const raw of Object.values(providers)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const block = raw as Record<string, unknown>;
|
||||
for (const [fieldName, value] of Object.entries(block)) {
|
||||
if (value === null) block[fieldName] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After injection, fail with a CLI-authoritative AUTH error if any configured
|
||||
* provider's `api_key` resolved empty (missing env var, or no bl login for
|
||||
* bailian). Replaces the SDK's raw `Environment variable '...' is not set` /
|
||||
* zod config error with a clean message plus a provider-specific hint. Validates
|
||||
* every declared provider, so a project is only runnable once all its providers'
|
||||
* keys are available; offline commands skip the check entirely.
|
||||
*/
|
||||
export function assertProviderCredentials(providers: Record<string, unknown>): void {
|
||||
for (const [name, raw] of Object.entries(providers)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const block = raw as Record<string, unknown>;
|
||||
if (!("api_key" in block)) continue;
|
||||
const apiKey = block.api_key;
|
||||
if (typeof apiKey === "string" && apiKey.trim()) continue;
|
||||
throw new BailianError(
|
||||
`Provider '${name}' is configured but its API key is empty.`,
|
||||
ExitCode.AUTH,
|
||||
CREDENTIAL_HINTS[name] ?? `Provide credentials for provider '${name}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { UserError } from "@openagentpack/sdk";
|
||||
import { type ApiErrorBody, BailianError, ExitCode, mapApiError } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Structural shape of the SDK's `ApiError` (thrown by provider clients on HTTP
|
||||
* 4xx/5xx). Matched on fields instead of `instanceof` because the installed SDK
|
||||
* version does not export the class yet, and structural matching keeps this
|
||||
* check stable across SDK versions either way.
|
||||
*/
|
||||
interface SdkApiErrorLike extends Error {
|
||||
statusCode: number;
|
||||
responseBody: string;
|
||||
}
|
||||
|
||||
function isSdkApiError(error: Error): error is SdkApiErrorLike {
|
||||
const candidate = error as Partial<SdkApiErrorLike>;
|
||||
return typeof candidate.statusCode === "number" && typeof candidate.responseBody === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK embeds the raw response body in its error message; recover the
|
||||
* structured fields (message / code / request_id) when the body is JSON so
|
||||
* `mapApiError` surfaces a clean server message plus api metadata. Non-JSON
|
||||
* bodies pass through verbatim as the message.
|
||||
*/
|
||||
function parseSdkResponseBody(raw: string): ApiErrorBody {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as ApiErrorBody;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return { message: raw.trim() || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK's session polling deadline surfaces as a plain `UserError` (no
|
||||
* dedicated timeout class as of SDK 0.3.x), so it is recognized by its stable
|
||||
* message shape: "Session did not complete within the timeout (N seconds)."
|
||||
* (session-runtime's assertNotTimedOut — the SDK's only timeout UserError).
|
||||
* It is a client-side wait limit, not a usage mistake → per bl's error
|
||||
* boundary it must exit TIMEOUT, not USAGE.
|
||||
*/
|
||||
function isSdkPollingTimeout(error: UserError): boolean {
|
||||
return /did not complete within the timeout/i.test(error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an SDK-backed operation, translating SDK error types into BailianError so
|
||||
* bl's error handler produces the right exit code and hint formatting.
|
||||
* SDK `UserError` → USAGE — except the polling-deadline UserError, which is a
|
||||
* client-side timeout → TIMEOUT with a wait-longer hint; SDK `ApiError`
|
||||
* (server HTTP error) → GENERAL via `mapApiError` (server message passed
|
||||
* through verbatim, with httpStatus/apiCode/requestId metadata for
|
||||
* --output json); fetch transport failures (`TypeError: fetch failed`) are
|
||||
* rethrown untouched so the runtime error handler maps them to NETWORK with an
|
||||
* errno-specific hint, matching the native client path; any other Error →
|
||||
* GENERAL (message passed through, per bl's "don't translate server errors"
|
||||
* boundary).
|
||||
*/
|
||||
export async function withAgentErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
if (error instanceof UserError) {
|
||||
if (isSdkPollingTimeout(error)) {
|
||||
throw new BailianError(
|
||||
error.message,
|
||||
ExitCode.TIMEOUT,
|
||||
// `bl` prefix is safe: agent commands ship on `bl` only.
|
||||
"The session may still be running — check `bl managed-agent session get --session-id <id>` or `session events`.",
|
||||
);
|
||||
}
|
||||
throw new BailianError(error.message, ExitCode.USAGE);
|
||||
}
|
||||
if (error instanceof Error && isSdkApiError(error)) {
|
||||
throw mapApiError(error.statusCode, parseSdkResponseBody(error.responseBody));
|
||||
}
|
||||
// DNS/TCP/TLS failures from the SDK's fetch: keep the original TypeError so
|
||||
// the runtime error handler classifies it as NETWORK (exit 6) + errno hint.
|
||||
if (error instanceof TypeError && error.message === "fetch failed") throw error;
|
||||
if (error instanceof Error) throw new BailianError(error.message, ExitCode.GENERAL);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RuntimeFeedbackEvent } from "@openagentpack/sdk";
|
||||
|
||||
/**
|
||||
* Render SDK runtime feedback to stderr, keeping stdout a clean data channel.
|
||||
* Used as the `onFeedback` sink for plan/apply so progress messages don't mix
|
||||
* with structured output.
|
||||
*/
|
||||
export function renderAgentFeedback(event: RuntimeFeedbackEvent): void {
|
||||
process.stderr.write(`${event.message}\n`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import {
|
||||
type IStateManager,
|
||||
LocalFileStateBackend,
|
||||
StateManager,
|
||||
type StateScope,
|
||||
} from "@openagentpack/sdk";
|
||||
|
||||
function createStateScope(configPath: string, projectName?: string): StateScope {
|
||||
const resolved = resolve(configPath);
|
||||
return { projectId: projectName ?? basename(dirname(resolved)) };
|
||||
}
|
||||
|
||||
/** Load or initialize a file-based StateManager (mirrors OpenAgentPack CLI). */
|
||||
export async function loadFileState(
|
||||
configPath: string,
|
||||
statePath?: string,
|
||||
projectName?: string,
|
||||
): Promise<IStateManager> {
|
||||
const resolved = resolve(configPath);
|
||||
const backend = new LocalFileStateBackend({ configPath: resolved, statePath });
|
||||
const path = backend.getStatePath(createStateScope(resolved, projectName));
|
||||
return StateManager.load(path);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface PagedResult<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
nextPage?: string;
|
||||
}
|
||||
|
||||
/** Fetch the first page, then follow cursors while `all` is true. */
|
||||
export async function fetchAllPages<T>(
|
||||
fetchPage: (page?: string) => Promise<PagedResult<T>>,
|
||||
all?: boolean,
|
||||
): Promise<PagedResult<T>> {
|
||||
const first = await fetchPage();
|
||||
const items = [...first.items];
|
||||
let hasMore = first.hasMore;
|
||||
let nextPage = first.nextPage;
|
||||
|
||||
while (all && nextPage) {
|
||||
const next = await fetchPage(nextPage);
|
||||
items.push(...next.items);
|
||||
hasMore = next.hasMore;
|
||||
nextPage = next.nextPage;
|
||||
}
|
||||
|
||||
return { items, hasMore, nextPage };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
type CollectedSessionEvents,
|
||||
isTerminalSessionStatus,
|
||||
type ProviderSessionEvent,
|
||||
} from "@openagentpack/sdk";
|
||||
import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/** Skip user echo + thinking noise in live rendering (mirrors OpenAgentPack CLI). */
|
||||
function shouldRenderLiveEvent(event: ProviderSessionEvent): boolean {
|
||||
return event.type !== "thinking" && !(event.type === "message" && event.role === "user");
|
||||
}
|
||||
|
||||
function renderTerminalStatus(status: string, json: boolean): void {
|
||||
if (json) return;
|
||||
process.stderr.write(`\n[session ${status}]\n`);
|
||||
}
|
||||
|
||||
function findLastSessionError(events: readonly ProviderSessionEvent[]): string | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index];
|
||||
if (event?.type === "error" && event.content?.trim()) return event.content;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function throwIfSessionFailed(status: string | undefined, message?: string): void {
|
||||
if (status !== "failed") return;
|
||||
throw new BailianError(message ?? "Session failed.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session identity echoed at the head of the `--output json` envelope so
|
||||
* callers can read the (possibly just-created) session id from stdout and
|
||||
* chain `session send/get/events/delete` — without scraping stderr.
|
||||
* Undefined fields are dropped by JSON.stringify.
|
||||
*/
|
||||
export interface SessionRenderContext {
|
||||
session_id?: string;
|
||||
provider?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume an SSE stream. Text mode renders live (assistant text → stdout,
|
||||
* diagnostics → stderr). JSON mode collects every event and emits exactly one
|
||||
* JSON document at the end — `--output json` guarantees a single valid JSON
|
||||
* result on stdout (mirrors `text chat --stream --output json`). `context`
|
||||
* prefixes the envelope with the session identity.
|
||||
*/
|
||||
export async function streamAndRenderEvents(
|
||||
events: AsyncIterable<ProviderSessionEvent>,
|
||||
json: boolean,
|
||||
context: SessionRenderContext = {},
|
||||
): Promise<void> {
|
||||
const collected: ProviderSessionEvent[] = [];
|
||||
let terminalStatus: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
for await (const event of events) {
|
||||
if (json) collected.push(event);
|
||||
else renderEvent(event);
|
||||
if (event.type === "error" && event.content?.trim()) errorMessage = event.content;
|
||||
if (event.type === "status" && isTerminalSessionStatus(event.status)) {
|
||||
terminalStatus = event.status;
|
||||
renderTerminalStatus(event.status ?? "", json);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...context, events: sanitizeSessionEvents(collected) }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
throwIfSessionFailed(terminalStatus, errorMessage);
|
||||
}
|
||||
|
||||
/** Assistant text → stdout (data channel); everything else → stderr (diagnostics). */
|
||||
function renderEvent(event: ProviderSessionEvent): void {
|
||||
if (!shouldRenderLiveEvent(event)) return;
|
||||
if (event.type === "message" && event.content) {
|
||||
process.stdout.write(event.content);
|
||||
} else if (event.type === "tool_use") {
|
||||
process.stderr.write(`\n[tool] ${event.tool_name}\n`);
|
||||
} else if (event.type === "tool_result" && event.content) {
|
||||
const preview =
|
||||
event.content.length > 200 ? `${event.content.slice(0, 200)}...` : event.content;
|
||||
process.stderr.write(`${preview}\n`);
|
||||
} else if (event.type === "status") {
|
||||
if (event.status === "running") process.stderr.write("\n[session running]\n");
|
||||
} else if (event.type === "error") {
|
||||
process.stderr.write(`\n[error] ${event.content ?? "unknown error"}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a polled (non-streaming) collected result. `context` prefixes the JSON envelope. */
|
||||
export function renderCollectedEvents(
|
||||
result: CollectedSessionEvents,
|
||||
json: boolean,
|
||||
context: SessionRenderContext = {},
|
||||
): void {
|
||||
if (json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...context,
|
||||
events: sanitizeSessionEvents(result.result.events),
|
||||
has_more: result.result.has_more,
|
||||
next_page: result.result.next_page,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
} else {
|
||||
for (const event of result.result.events) renderEvent(event);
|
||||
renderTerminalStatus(result.terminalStatus, json);
|
||||
}
|
||||
throwIfSessionFailed(result.terminalStatus, findLastSessionError(result.result.events));
|
||||
}
|
||||
|
||||
/** Split a comma-separated --memory-stores value. */
|
||||
export function parseMemoryStores(value?: string): string[] | undefined {
|
||||
return value
|
||||
? value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as sdk from "@openagentpack/sdk";
|
||||
import {
|
||||
createInstrumentedFetch,
|
||||
type FetchImplementation,
|
||||
type Identity,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
/** The slice of CommandContext the transport wrapper needs (UA identity + verbose). */
|
||||
export interface HostContext {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Route the SDK's provider-client requests through the CLI's instrumented
|
||||
* fetch (UA, host-gated tracking headers, --verbose logging). Feature-detected:
|
||||
* `setDefaultFetch` landed after @openagentpack/sdk 0.1.0 — on older versions
|
||||
* this is a silent no-op and the SDK keeps using the global fetch as before.
|
||||
*/
|
||||
export function installSdkTransport(host: HostContext): void {
|
||||
if (installed) return;
|
||||
const setDefaultFetch = (sdk as Record<string, unknown>).setDefaultFetch;
|
||||
if (typeof setDefaultFetch !== "function") return;
|
||||
(setDefaultFetch as (fetchImpl: FetchImplementation) => void)(createInstrumentedFetch(host));
|
||||
installed = true;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { executePlannedProject, planProjectContext } from "@openagentpack/sdk";
|
||||
import { formatResourceLabel } from "./_engine/address-utils.ts";
|
||||
import {
|
||||
assertProviderConfigured,
|
||||
buildAgentRuntime,
|
||||
CREDENTIALS_NOTE,
|
||||
} from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { renderAgentFeedback } from "./_engine/feedback.ts";
|
||||
|
||||
const APPLY_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider (default: all configured)",
|
||||
},
|
||||
yes: {
|
||||
type: "switch",
|
||||
description: "Confirm and apply without an interactive prompt (required to mutate)",
|
||||
},
|
||||
noRefresh: {
|
||||
type: "switch",
|
||||
description: "Skip refreshing state from remote before planning",
|
||||
},
|
||||
concurrency: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Max independent resources to apply in parallel (default 6, max 10)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Apply planned changes to create/update/delete agent resources",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--file <path>] [--provider <name>] [--yes] [--concurrency <n>]",
|
||||
flags: APPLY_FLAGS,
|
||||
exampleArgs: ["--yes", "--provider bailian --yes"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_apply: {
|
||||
provider: flags.provider ?? "all",
|
||||
refresh: !flags.noRefresh,
|
||||
concurrency: flags.concurrency,
|
||||
},
|
||||
config_file: file,
|
||||
hint: "Run `managed-agent plan` to preview the exact resource changes.",
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const planned = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
assertProviderConfigured(runtime, flags.provider);
|
||||
return planProjectContext(runtime, {
|
||||
provider: flags.provider,
|
||||
refresh: !flags.noRefresh,
|
||||
quiet: true,
|
||||
onFeedback: renderAgentFeedback,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const plan = planned.plan;
|
||||
// In --output json, stdout must stay a single-JSON data channel: diagnostics
|
||||
// and the action preview are progress info → stderr; text mode keeps stdout.
|
||||
const emitProgress = (line: string): void => {
|
||||
if (format === "json") process.stderr.write(`${line}\n`);
|
||||
else emitBare(line);
|
||||
};
|
||||
if (plan.diagnostics.some((diag) => diag.severity === "error")) {
|
||||
for (const diag of plan.diagnostics) {
|
||||
if (diag.severity === "error") emitProgress(`[error] ${diag.code}: ${diag.message}`);
|
||||
}
|
||||
throw new BailianError("Cannot apply: resolve the errors above first.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const actionable = plan.actions.filter((action) => action.action !== "no-op");
|
||||
if (actionable.length === 0) {
|
||||
if (format === "json")
|
||||
emitResult({ succeeded: 0, failed: 0, skipped: 0, results: [] }, format);
|
||||
else emitBare("No changes. Infrastructure is up-to-date.");
|
||||
return;
|
||||
}
|
||||
|
||||
const creates = actionable.filter((action) => action.action === "create").length;
|
||||
const updates = actionable.filter((action) => action.action === "update").length;
|
||||
const deletes = planned.destructiveActions;
|
||||
|
||||
for (const action of actionable) {
|
||||
const icon = action.action === "create" ? "+" : action.action === "update" ? "~" : "-";
|
||||
emitProgress(` ${icon} ${formatResourceLabel(action.address)}`);
|
||||
}
|
||||
|
||||
if (!flags.yes) {
|
||||
throw new BailianError(
|
||||
`Refusing to apply ${actionable.length} change(s) (${creates} create, ${updates} update, ${deletes.length} destroy) without confirmation.`,
|
||||
ExitCode.USAGE,
|
||||
"Review with `bl managed-agent plan`, then re-run with --yes to apply.",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await withAgentErrors(() =>
|
||||
withStdoutProtected(() =>
|
||||
executePlannedProject(planned, {
|
||||
onFeedback: renderAgentFeedback,
|
||||
policy: "force",
|
||||
concurrency: flags.concurrency,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const succeeded = result.results.filter((entry) => entry.status === "success").length;
|
||||
const failed = result.results.filter((entry) => entry.status === "failed").length;
|
||||
const skipped = result.results.filter((entry) => entry.status === "skipped").length;
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ succeeded, failed, skipped, results: result.results }, format);
|
||||
} else {
|
||||
emitBare(`\nApply finished: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped.`);
|
||||
}
|
||||
|
||||
if (failed > 0) throw new BailianError("Apply failed.", ExitCode.GENERAL);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { destroyPlannedProjectResources, planDestroyProjectContext } from "@openagentpack/sdk";
|
||||
import { formatResourceLabel } from "./_engine/address-utils.ts";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const DESTROY_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
yes: {
|
||||
type: "switch",
|
||||
description: "Confirm and destroy without an interactive prompt (required)",
|
||||
},
|
||||
cascade: {
|
||||
type: "switch",
|
||||
description: "Auto-delete dependent resources (e.g. sessions referencing an environment)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Destroy all managed agent resources tracked in state",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--file <path>] [--yes] [--cascade]",
|
||||
flags: DESTROY_FLAGS,
|
||||
exampleArgs: ["--yes", "--yes --cascade"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_destroy: { cascade: Boolean(flags.cascade) },
|
||||
config_file: file,
|
||||
hint: "Run `managed-agent state list` to see the resources tracked in state.",
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const planned = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
return planDestroyProjectContext(runtime);
|
||||
}),
|
||||
);
|
||||
|
||||
const resources = planned.resources;
|
||||
if (resources.length === 0) {
|
||||
if (format === "json") emitResult({ destroyed: 0, total: 0 }, format);
|
||||
else emitBare("No resources in state. Nothing to destroy.");
|
||||
return;
|
||||
}
|
||||
|
||||
// In --output json, stdout must stay a single-JSON data channel: the
|
||||
// resource preview is progress info → stderr; text mode keeps stdout.
|
||||
for (const resource of resources) {
|
||||
const line = ` - ${formatResourceLabel(resource.address)} [${resource.remote_id}]`;
|
||||
if (format === "json") process.stderr.write(`${line}\n`);
|
||||
else emitBare(line);
|
||||
}
|
||||
|
||||
if (!flags.yes) {
|
||||
throw new BailianError(
|
||||
`Refusing to destroy ${resources.length} resource(s) without confirmation.`,
|
||||
ExitCode.USAGE,
|
||||
"Re-run with --yes to destroy (add --cascade to remove dependents).",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await withAgentErrors(() =>
|
||||
withStdoutProtected(() =>
|
||||
destroyPlannedProjectResources(planned, {
|
||||
cascade: flags.cascade,
|
||||
onCascadeRequired: async () => Boolean(flags.cascade),
|
||||
onResourceResult: (item) => {
|
||||
const label = formatResourceLabel(item.resource.address);
|
||||
process.stderr.write(` ${item.status === "success" ? "✓" : "✗"} ${label}\n`);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ destroyed: result.destroyed, total: result.resources.length }, format);
|
||||
} else {
|
||||
const status = result.partial ? "Destroy incomplete" : "Destroy complete";
|
||||
emitBare(`\n${status}. ${result.destroyed}/${result.resources.length} resources removed.`);
|
||||
}
|
||||
|
||||
if (result.partial) {
|
||||
const firstFailure = result.results.find((item) => item.status !== "success");
|
||||
throw new BailianError(
|
||||
firstFailure?.error ||
|
||||
`Destroy incomplete: ${result.destroyed}/${result.resources.length} resources removed.`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const GITIGNORE_ADDITIONS = `
|
||||
# agents
|
||||
agents.state.json
|
||||
.env
|
||||
`;
|
||||
|
||||
const PROVIDERS = ["bailian", "claude", "qoder", "ark", "all"] as const;
|
||||
|
||||
const PROVIDER_BLOCKS: Record<string, string> = {
|
||||
bailian: ` bailian:\n # bl auth login --api-key <key> sets DASHSCOPE_API_KEY; --base-url <url> sets BAILIAN_BASE_URL\n api_key: \${DASHSCOPE_API_KEY}\n base_url: \${BAILIAN_BASE_URL}`,
|
||||
claude: ` claude:\n api_key: \${ANTHROPIC_API_KEY}`,
|
||||
qoder: ` qoder:\n api_key: \${QODER_PAT}\n gateway: "https://api.qoder.com/api/v1/cloud"`,
|
||||
ark: ` ark:\n api_key: \${ARK_API_KEY}`,
|
||||
};
|
||||
|
||||
const SINGLE_MODEL: Record<string, string> = {
|
||||
bailian: ` model: qwen3.7-max`,
|
||||
claude: ` model: claude-sonnet-4-6`,
|
||||
qoder: ` model: ultimate`,
|
||||
ark: ` model: doubao-seed-2-1-pro-260628`,
|
||||
};
|
||||
|
||||
function buildTemplate(options: { provider: string; agentName: string }): string {
|
||||
const providerBlock =
|
||||
options.provider === "all"
|
||||
? `${PROVIDER_BLOCKS.bailian}\n${PROVIDER_BLOCKS.claude}\n${PROVIDER_BLOCKS.qoder}\n${PROVIDER_BLOCKS.ark}`
|
||||
: PROVIDER_BLOCKS[options.provider]!;
|
||||
|
||||
const modelBlock =
|
||||
options.provider === "all"
|
||||
? ` model:\n bailian: qwen3.7-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628`
|
||||
: SINGLE_MODEL[options.provider]!;
|
||||
|
||||
const toolBlock =
|
||||
options.provider === "bailian"
|
||||
? "[bash, read, glob, grep]"
|
||||
: "[read, glob, grep, web_search, web_fetch]";
|
||||
|
||||
return `version: "1"
|
||||
|
||||
providers:
|
||||
${providerBlock}
|
||||
|
||||
defaults:
|
||||
provider: ${options.provider === "all" ? "all" : options.provider}
|
||||
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
type: cloud
|
||||
networking:
|
||||
type: unrestricted
|
||||
|
||||
agents:
|
||||
${options.agentName}:
|
||||
description: "General-purpose assistant"
|
||||
${modelBlock}
|
||||
instructions: |
|
||||
You are a helpful assistant.
|
||||
environment: dev
|
||||
tools:
|
||||
builtin: ${toolBlock}
|
||||
`;
|
||||
}
|
||||
|
||||
const INIT_FLAGS = {
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Provider: bailian, claude, qoder, ark, all (default: bailian)",
|
||||
choices: PROVIDERS,
|
||||
},
|
||||
agentName: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Name of the first agent (default: assistant)",
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Output config path (default: agents.yaml)",
|
||||
},
|
||||
force: {
|
||||
type: "switch",
|
||||
description: "Overwrite an existing config file",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a new agents.yaml template",
|
||||
auth: "none",
|
||||
usageArgs: "[--provider <name>] [--agent-name <name>] [--file <path>] [--force]",
|
||||
flags: INIT_FLAGS,
|
||||
exampleArgs: ["", "--provider bailian --agent-name assistant", "--provider all"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const provider = flags.provider ?? "bailian";
|
||||
const agentName = flags.agentName ?? "assistant";
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (existsSync(file) && !flags.force) {
|
||||
throw new BailianError(
|
||||
`${file} already exists.`,
|
||||
ExitCode.USAGE,
|
||||
"Pass --force to overwrite.",
|
||||
);
|
||||
}
|
||||
|
||||
const gitignorePath = ".gitignore";
|
||||
|
||||
if (settings.dryRun) {
|
||||
let wouldUpdateGitignore = true;
|
||||
if (existsSync(gitignorePath)) {
|
||||
const content = await readFile(gitignorePath, "utf8");
|
||||
wouldUpdateGitignore = !content.includes("agents.state.json");
|
||||
}
|
||||
emitResult(
|
||||
{
|
||||
would_create: file,
|
||||
provider,
|
||||
agent: agentName,
|
||||
would_update_gitignore: wouldUpdateGitignore,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = buildTemplate({ provider, agentName });
|
||||
await writeFile(file, template, "utf8");
|
||||
|
||||
if (existsSync(gitignorePath)) {
|
||||
const content = await readFile(gitignorePath, "utf8");
|
||||
if (!content.includes("agents.state.json")) {
|
||||
await writeFile(gitignorePath, content + GITIGNORE_ADDITIONS, "utf8");
|
||||
}
|
||||
} else {
|
||||
await writeFile(gitignorePath, `${GITIGNORE_ADDITIONS.trim()}\n`, "utf8");
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ created: file, provider, agent: agentName }, format);
|
||||
} else {
|
||||
emitBare(`Created ${file}`);
|
||||
if (provider === "bailian" || provider === "all") {
|
||||
emitBare(
|
||||
"Credentials: run `bl auth login --api-key <key> --base-url <url>`, or set DASHSCOPE_API_KEY / BAILIAN_BASE_URL.",
|
||||
);
|
||||
}
|
||||
emitBare("Next: edit agents.yaml, then run `bl managed-agent plan`.");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { planProjectContext } from "@openagentpack/sdk";
|
||||
import { formatResourceLabel } from "./_engine/address-utils.ts";
|
||||
import {
|
||||
assertProviderConfigured,
|
||||
buildAgentRuntime,
|
||||
CREDENTIALS_NOTE,
|
||||
} from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { renderAgentFeedback } from "./_engine/feedback.ts";
|
||||
|
||||
const PLAN_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider (default: all configured)",
|
||||
},
|
||||
noRefresh: {
|
||||
type: "switch",
|
||||
description: "Skip refreshing state from remote before planning",
|
||||
},
|
||||
refreshOnly: {
|
||||
type: "switch",
|
||||
description: "Refresh state and show drift without planning remote mutations",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show what changes would be applied to agent infrastructure",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--file <path>] [--provider <name>] [--no-refresh] [--refresh-only]",
|
||||
flags: PLAN_FLAGS,
|
||||
exampleArgs: ["", "--provider bailian", "--no-refresh"],
|
||||
notes: [
|
||||
...CREDENTIALS_NOTE,
|
||||
"--no-refresh and --dry-run plan offline from local config and state: no remote requests, no state writes, provider keys are not checked.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
// Offline mode never talks to a provider and never saves refreshed state:
|
||||
// --no-refresh by explicit request, --dry-run by contract (read-only run).
|
||||
// Provider keys are skipped then; the bl login gate (auth: "apiKey") still
|
||||
// applies except under --dry-run (authStage's dry-run exemption).
|
||||
const offline = Boolean(flags.noRefresh) || settings.dryRun;
|
||||
|
||||
const planned = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file, {
|
||||
credentials: offline ? "none" : "all",
|
||||
});
|
||||
assertProviderConfigured(runtime, flags.provider);
|
||||
return planProjectContext(runtime, {
|
||||
provider: flags.provider,
|
||||
refresh: !offline,
|
||||
quiet: format === "json",
|
||||
onFeedback: format === "json" ? undefined : renderAgentFeedback,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const plan = planned.plan;
|
||||
const hasErrors = plan.diagnostics.some((diag) => diag.severity === "error");
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(plan, format);
|
||||
if (hasErrors) throw new BailianError("Plan contains errors.", ExitCode.GENERAL);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const diag of plan.diagnostics) {
|
||||
emitBare(`[${diag.severity}] ${diag.code}: ${diag.message}`);
|
||||
}
|
||||
if (hasErrors) throw new BailianError("Plan contains errors.", ExitCode.GENERAL);
|
||||
|
||||
const creates = plan.actions.filter((action) => action.action === "create");
|
||||
const updates = plan.actions.filter((action) => action.action === "update");
|
||||
const deletes = plan.actions.filter((action) => action.action === "delete");
|
||||
|
||||
if (creates.length + updates.length + deletes.length === 0) {
|
||||
emitBare("No changes. Infrastructure is up-to-date.");
|
||||
if (flags.refreshOnly) emitBare("Refresh-only mode: no remote mutations were performed.");
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare("\nPlanned actions:\n");
|
||||
for (const action of creates) emitBare(` + ${formatResourceLabel(action.address)}`);
|
||||
for (const action of updates) {
|
||||
emitBare(` ~ ${formatResourceLabel(action.address)}`);
|
||||
if (action.reason) emitBare(` ${action.reason}`);
|
||||
}
|
||||
for (const action of deletes) emitBare(` - ${formatResourceLabel(action.address)}`);
|
||||
emitBare(
|
||||
`\nPlan: ${creates.length} to create, ${updates.length} to update, ${deletes.length} to destroy.`,
|
||||
);
|
||||
if (flags.refreshOnly) emitBare("Refresh-only mode: no remote mutations will be performed.");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { createSessionForAgent } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { parseMemoryStores } from "./_engine/session-render.ts";
|
||||
|
||||
const SESSION_CREATE_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Agent name (auto-detected when only one agent is configured)",
|
||||
},
|
||||
environment: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Override agent's declared environment",
|
||||
},
|
||||
vault: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Override agent's declared vault",
|
||||
},
|
||||
memoryStores: {
|
||||
type: "string",
|
||||
valueHint: "<names>",
|
||||
description: "Override agent's memory stores (comma-separated)",
|
||||
},
|
||||
title: { type: "string", valueHint: "<title>", description: "Session title" },
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider (multi-provider agents)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a new session for an agent",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--agent <name>] [--environment <name>] [--title <title>] [--file <path>]",
|
||||
flags: SESSION_CREATE_FLAGS,
|
||||
exampleArgs: ["", "--agent assistant", "--agent assistant --title 'debug run'"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_create_session: {
|
||||
agent: flags.agent ?? "auto",
|
||||
provider: flags.provider ?? "auto",
|
||||
environment: flags.environment,
|
||||
vault: flags.vault,
|
||||
memory_stores: parseMemoryStores(flags.memoryStores),
|
||||
title: flags.title,
|
||||
},
|
||||
config_file: file,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const run = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
return createSessionForAgent(runtime, {
|
||||
agent: flags.agent,
|
||||
provider: flags.provider,
|
||||
environment: flags.environment,
|
||||
vault: flags.vault,
|
||||
memoryStores: parseMemoryStores(flags.memoryStores),
|
||||
title: flags.title,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const { agentName, session } = run;
|
||||
if (format === "json") {
|
||||
emitResult({ agent: agentName, session }, format);
|
||||
return;
|
||||
}
|
||||
emitBare(`Session created: ${session.id}`);
|
||||
emitBare(` Agent: ${agentName}`);
|
||||
emitBare(` Environment: ${session.environment_id}`);
|
||||
emitBare(` Status: ${session.status}`);
|
||||
if (session.vault_ids.length) emitBare(` Vaults: ${session.vault_ids.join(", ")}`);
|
||||
if (session.memory_store_ids.length) {
|
||||
emitBare(` Memory: ${session.memory_store_ids.join(", ")}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { deleteSession } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const SESSION_DELETE_FLAGS = {
|
||||
sessionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Session ID (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Delete a session",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--session-id <id> [--provider <name>] [--file <path>]",
|
||||
flags: SESSION_DELETE_FLAGS,
|
||||
exampleArgs: ["--session-id sess_abc123"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_delete_session: flags.sessionId,
|
||||
provider: flags.provider ?? "auto",
|
||||
config_file: file,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
await deleteSession(runtime, flags.sessionId, flags.provider);
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "json") emitResult({ deleted: flags.sessionId }, format);
|
||||
else emitBare(`Session ${flags.sessionId} deleted.`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { listSessionEvents } from "@openagentpack/sdk";
|
||||
import { sanitizeSessionEvents } from "@openagentpack/sdk/session-events";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { fetchAllPages } from "./_engine/pagination.ts";
|
||||
|
||||
const SESSION_EVENTS_FLAGS = {
|
||||
sessionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Session ID (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Maximum number of events to fetch",
|
||||
},
|
||||
all: {
|
||||
type: "switch",
|
||||
description: "Fetch all pages by following the cursor",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List event history for a session",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--session-id <id> [--limit <n>] [--all] [--file <path>]",
|
||||
flags: SESSION_EVENTS_FLAGS,
|
||||
exampleArgs: ["--session-id sess_abc123", "--session-id sess_abc123 --all"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
const { items: events, hasMore } = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
return fetchAllPages(async (page) => {
|
||||
const result = await listSessionEvents(runtime, flags.sessionId, {
|
||||
provider: flags.provider,
|
||||
limit: flags.limit,
|
||||
page_token: page,
|
||||
});
|
||||
return {
|
||||
items: result.events,
|
||||
hasMore: result.has_more,
|
||||
nextPage: result.next_page,
|
||||
};
|
||||
}, flags.all);
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ events: sanitizeSessionEvents(events), has_more: hasMore }, format);
|
||||
return;
|
||||
}
|
||||
if (events.length === 0) {
|
||||
emitBare("No events found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ["#", "TYPE", "CONTENT"];
|
||||
const rows = events.map((event, index) => {
|
||||
let preview = "";
|
||||
if (event.type === "message") preview = (event.content ?? "").slice(0, 60);
|
||||
else if (event.type === "tool_use") preview = event.tool_name ?? "";
|
||||
else if (event.type === "tool_result") preview = (event.content ?? "").slice(0, 60);
|
||||
else if (event.type === "status") preview = event.status ?? "";
|
||||
else if (event.type === "error") preview = (event.content ?? "").slice(0, 60);
|
||||
else preview = event.raw_type;
|
||||
return [String(index + 1), event.type, preview];
|
||||
});
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${events.length}`);
|
||||
if (hasMore) emitBare("More events available. Use --all to fetch all.");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { getSession } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const SESSION_GET_FLAGS = {
|
||||
sessionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Session ID (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Get details of a session",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--session-id <id> [--provider <name>] [--file <path>]",
|
||||
flags: SESSION_GET_FLAGS,
|
||||
exampleArgs: ["--session-id sess_abc123"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
const session = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
return getSession(runtime, flags.sessionId, flags.provider);
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(session, format);
|
||||
return;
|
||||
}
|
||||
emitBare(` ID: ${session.id}`);
|
||||
emitBare(` Agent: ${session.agent_id}`);
|
||||
emitBare(` Environment: ${session.environment_id}`);
|
||||
emitBare(` Status: ${session.status}`);
|
||||
if (session.title) emitBare(` Title: ${session.title}`);
|
||||
emitBare(` Created: ${session.created_at}`);
|
||||
emitBare(` Updated: ${session.updated_at}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { listSessionSummaries } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { fetchAllPages } from "./_engine/pagination.ts";
|
||||
|
||||
const SESSION_LIST_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Filter by agent name",
|
||||
},
|
||||
all: {
|
||||
type: "switch",
|
||||
description: "Fetch all pages by following the cursor",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List sessions from the provider",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--agent <name>] [--all] [--provider <name>] [--file <path>]",
|
||||
flags: SESSION_LIST_FLAGS,
|
||||
exampleArgs: ["", "--agent assistant", "--all"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
const { items: summaries, hasMore } = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
return fetchAllPages(async (page) => {
|
||||
const result = await listSessionSummaries(runtime, {
|
||||
agent: flags.agent,
|
||||
provider: flags.provider,
|
||||
filter: page ? { page } : undefined,
|
||||
});
|
||||
return {
|
||||
items: result.summaries,
|
||||
hasMore: result.hasMore,
|
||||
nextPage: result.nextPage,
|
||||
};
|
||||
}, flags.all);
|
||||
}),
|
||||
);
|
||||
|
||||
const sessions = summaries.map((summary) => summary.session);
|
||||
if (format === "json") {
|
||||
emitResult({ sessions, has_more: hasMore }, format);
|
||||
return;
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
emitBare("No sessions found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const agentNames = new Map(
|
||||
summaries
|
||||
.filter((summary) => summary.agentName)
|
||||
.map((summary) => [summary.session.id, summary.agentName!]),
|
||||
);
|
||||
const headers = ["ID", "TITLE", "AGENT", "STATUS", "CREATED"];
|
||||
const rows = sessions.map((session) => [
|
||||
session.id,
|
||||
(session.title ?? "").slice(0, 20),
|
||||
agentNames.get(session.id) ?? session.agent_id.slice(0, 12),
|
||||
session.status,
|
||||
session.created_at,
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${sessions.length}`);
|
||||
if (hasMore) emitBare("More sessions available. Use --all to fetch all.");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { startSessionRun, startSessionRunPolling } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import {
|
||||
parseMemoryStores,
|
||||
renderCollectedEvents,
|
||||
streamAndRenderEvents,
|
||||
} from "./_engine/session-render.ts";
|
||||
|
||||
const SESSION_RUN_FLAGS = {
|
||||
prompt: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Prompt to send (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Agent name (auto-detected when only one agent is configured)",
|
||||
},
|
||||
environment: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Override agent's declared environment",
|
||||
},
|
||||
vault: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Override agent's declared vault",
|
||||
},
|
||||
memoryStores: {
|
||||
type: "string",
|
||||
valueHint: "<names>",
|
||||
description: "Override agent's memory stores (comma-separated)",
|
||||
},
|
||||
title: { type: "string", valueHint: "<title>", description: "Session title" },
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
noStream: {
|
||||
type: "switch",
|
||||
description: "Use polling instead of SSE streaming",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Create a session, send a message, and stream the response",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--prompt <text> [--agent <name>] [--no-stream] [--file <path>]",
|
||||
flags: SESSION_RUN_FLAGS,
|
||||
exampleArgs: ['--prompt "hello"', '--agent assistant --prompt "summarize this repo"'],
|
||||
notes: [
|
||||
...CREDENTIALS_NOTE,
|
||||
"--output json emits one envelope: { session_id, provider, agent, events } — read session_id to chain `session send/get/events/delete`.",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
const asJson = format === "json";
|
||||
|
||||
const runOptions = {
|
||||
agent: flags.agent,
|
||||
provider: flags.provider,
|
||||
environment: flags.environment,
|
||||
vault: flags.vault,
|
||||
memoryStores: parseMemoryStores(flags.memoryStores),
|
||||
title: flags.title,
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_run: {
|
||||
prompt: flags.prompt,
|
||||
agent: flags.agent ?? "auto",
|
||||
provider: flags.provider ?? "auto",
|
||||
environment: flags.environment,
|
||||
vault: flags.vault,
|
||||
memory_stores: runOptions.memoryStores,
|
||||
title: flags.title,
|
||||
mode: flags.noStream ? "polling" : "streaming",
|
||||
},
|
||||
config_file: file,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
if (flags.noStream) {
|
||||
const run = await startSessionRunPolling(runtime, flags.prompt, runOptions);
|
||||
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
|
||||
renderCollectedEvents(run, asJson, {
|
||||
session_id: run.session.id,
|
||||
provider: run.provider,
|
||||
agent: run.agentName,
|
||||
});
|
||||
} else {
|
||||
const run = await startSessionRun(runtime, flags.prompt, runOptions);
|
||||
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
|
||||
await streamAndRenderEvents(run.events, asJson, {
|
||||
session_id: run.session.id,
|
||||
provider: run.provider,
|
||||
agent: run.agentName,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { sendSessionMessagePolling, sendSessionMessageStreaming } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
import { renderCollectedEvents, streamAndRenderEvents } from "./_engine/session-render.ts";
|
||||
|
||||
const SESSION_SEND_FLAGS = {
|
||||
sessionId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Session ID (required)",
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
valueHint: "<text>",
|
||||
description: "Message to send (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
noStream: {
|
||||
type: "switch",
|
||||
description: "Use polling instead of SSE streaming",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Send a message to an existing session and stream the response",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--session-id <id> --message <text> [--no-stream] [--file <path>]",
|
||||
flags: SESSION_SEND_FLAGS,
|
||||
exampleArgs: ['--session-id sess_abc123 --message "continue"'],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
const asJson = format === "json";
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
would_send: {
|
||||
session_id: flags.sessionId,
|
||||
message: flags.message,
|
||||
provider: flags.provider ?? "auto",
|
||||
mode: flags.noStream ? "polling" : "streaming",
|
||||
},
|
||||
config_file: file,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
if (flags.noStream) {
|
||||
const result = await sendSessionMessagePolling(runtime, flags.sessionId, flags.message, {
|
||||
provider: flags.provider,
|
||||
});
|
||||
renderCollectedEvents(result, asJson, {
|
||||
session_id: flags.sessionId,
|
||||
});
|
||||
} else {
|
||||
const events = await sendSessionMessageStreaming(
|
||||
runtime,
|
||||
flags.sessionId,
|
||||
flags.message,
|
||||
{
|
||||
provider: flags.provider,
|
||||
},
|
||||
);
|
||||
await streamAndRenderEvents(events, asJson, {
|
||||
session_id: flags.sessionId,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { listSkills } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const SKILL_SOURCES = ["custom", "official", "all"] as const;
|
||||
type SkillSource = (typeof SKILL_SOURCES)[number];
|
||||
|
||||
const SKILL_LIST_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
source: {
|
||||
type: "string",
|
||||
valueHint: "<source>",
|
||||
description:
|
||||
"Skill catalog: custom (workspace-uploaded, default), official (built-in), or all (both catalogs in one call)",
|
||||
},
|
||||
provider: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Target provider",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List skills from the provider's skill catalog",
|
||||
auth: "apiKey",
|
||||
usageArgs: "[--source custom|official|all] [--provider <name>] [--file <path>]",
|
||||
flags: SKILL_LIST_FLAGS,
|
||||
exampleArgs: [
|
||||
"",
|
||||
"--source official",
|
||||
"--source all --output json",
|
||||
"--source custom --provider bailian",
|
||||
],
|
||||
notes: [
|
||||
...CREDENTIALS_NOTE,
|
||||
"Providers without a skill listing API (e.g. ark) return an empty list.",
|
||||
"For agent-driven skill selection, use `--source all --output json`: one call returns both catalogs with per-skill `source` and `description` fields to pick from.",
|
||||
"When generating a task that needs a suitable skill, call this command to match official or custom skills before wiring them into the task.",
|
||||
],
|
||||
validate: (f) =>
|
||||
f.source && !SKILL_SOURCES.includes(f.source as SkillSource)
|
||||
? "--source must be one of: custom, official, all."
|
||||
: undefined,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
const source = (flags.source as SkillSource | undefined) ?? "custom";
|
||||
|
||||
const skills = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
if (source !== "all") {
|
||||
return listSkills(runtime, { provider: flags.provider, source });
|
||||
}
|
||||
// Both catalogs in one call; each entry carries its own `source` field.
|
||||
const [customSkills, officialSkills] = await Promise.all([
|
||||
listSkills(runtime, { provider: flags.provider, source: "custom" }),
|
||||
listSkills(runtime, { provider: flags.provider, source: "official" }),
|
||||
]);
|
||||
return [...customSkills, ...officialSkills];
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ source, skills }, format);
|
||||
return;
|
||||
}
|
||||
if (skills.length === 0) {
|
||||
emitBare(source === "all" ? "No skills found." : `No ${source} skills found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ["ID", "NAME", "SOURCE", "STATUS", "VERSION", "CREATED"];
|
||||
const rows = skills.map((skill) => [
|
||||
skill.id,
|
||||
skill.name.slice(0, 32),
|
||||
skill.source,
|
||||
skill.status,
|
||||
skill.latest_version ?? "-",
|
||||
skill.created_at ?? "-",
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${skills.length} (${source})`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { importResource, parseStateAddress } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const STATE_IMPORT_FLAGS = {
|
||||
address: {
|
||||
type: "string",
|
||||
valueHint: "<provider.type.name>",
|
||||
description: "Resource state address (required)",
|
||||
required: true,
|
||||
},
|
||||
remoteId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Existing remote resource ID to import (required)",
|
||||
required: true,
|
||||
},
|
||||
resourceVersion: {
|
||||
type: "number",
|
||||
valueHint: "<n>",
|
||||
description: "Resource version (for versioned resources like agents)",
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Import an existing remote resource into agents state",
|
||||
auth: "apiKey",
|
||||
usageArgs:
|
||||
"--address <provider.type.name> --remote-id <id> [--resource-version <n>] [--file <path>]",
|
||||
flags: STATE_IMPORT_FLAGS,
|
||||
exampleArgs: ["--address bailian.agent.assistant --remote-id agent-abc123"],
|
||||
notes: CREDENTIALS_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (settings.dryRun) {
|
||||
// Validate the address shape locally so dry-run still catches usage errors.
|
||||
await withAgentErrors(async () => {
|
||||
parseStateAddress(flags.address, { requireProvider: true });
|
||||
});
|
||||
emitResult(
|
||||
{
|
||||
would_import: flags.address,
|
||||
remote_id: flags.remoteId,
|
||||
resource_version: flags.resourceVersion,
|
||||
config_file: file,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
// Parse first so a malformed address fails fast, before any config I/O.
|
||||
const parsed = parseStateAddress(flags.address, {
|
||||
requireProvider: true,
|
||||
});
|
||||
const runtime = await buildAgentRuntime(ctx, file);
|
||||
await importResource(runtime, parsed, flags.remoteId, {
|
||||
resourceVersion: flags.resourceVersion,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ imported: flags.address, remote_id: flags.remoteId }, format);
|
||||
} else {
|
||||
emitBare(`Imported ${flags.address} (remote_id: ${flags.remoteId}) into state.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
import { buildAgentRuntime, OFFLINE_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const STATE_LIST_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "List resources tracked in agents state",
|
||||
auth: "none",
|
||||
usageArgs: "[--file <path>]",
|
||||
flags: STATE_LIST_FLAGS,
|
||||
exampleArgs: ["", "--file agents.yaml"],
|
||||
notes: OFFLINE_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
const resources = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file, {
|
||||
credentials: "none",
|
||||
});
|
||||
return runtime.state.listResources();
|
||||
}),
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ resources }, format);
|
||||
return;
|
||||
}
|
||||
if (resources.length === 0) {
|
||||
emitBare("No resources tracked in state.");
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ["TYPE", "NAME", "PROVIDER", "REMOTE ID"];
|
||||
const rows = resources.map((resource) => [
|
||||
resource.address.type,
|
||||
resource.address.name,
|
||||
resource.address.provider,
|
||||
resource.remote_id ?? "(local)",
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${resources.length}`);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { parseStateAddress } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, OFFLINE_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const STATE_RM_FLAGS = {
|
||||
address: {
|
||||
type: "string",
|
||||
valueHint: "<provider.type.name>",
|
||||
description: "Resource state address (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Remove a resource from state without destroying it remotely",
|
||||
auth: "none",
|
||||
usageArgs: "--address <provider.type.name> [--file <path>]",
|
||||
flags: STATE_RM_FLAGS,
|
||||
exampleArgs: ["--address bailian.agent.assistant"],
|
||||
notes: OFFLINE_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
if (settings.dryRun) {
|
||||
// Validate the address shape locally so dry-run still catches usage errors.
|
||||
await withAgentErrors(async () => {
|
||||
parseStateAddress(flags.address, { requireProvider: false });
|
||||
});
|
||||
emitResult({ would_remove: flags.address, config_file: file }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file, {
|
||||
credentials: "none",
|
||||
});
|
||||
const parsed = parseStateAddress(flags.address, {
|
||||
requireProvider: false,
|
||||
});
|
||||
const found = runtime.state.findResource(parsed);
|
||||
if (!found) {
|
||||
throw new BailianError(`Resource not found: ${flags.address}`, ExitCode.GENERAL);
|
||||
}
|
||||
runtime.state.removeResource(found.address);
|
||||
await runtime.state.save();
|
||||
}),
|
||||
);
|
||||
|
||||
const message = `Removed ${flags.address} from state (remote resource not deleted).`;
|
||||
if (format === "json") emitResult({ removed: flags.address }, format);
|
||||
else emitBare(message);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { parseStateAddress } from "@openagentpack/sdk";
|
||||
import { buildAgentRuntime, OFFLINE_NOTE } from "./_engine/config-loader.ts";
|
||||
import { withStdoutProtected } from "./_engine/console-capture.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const STATE_SHOW_FLAGS = {
|
||||
address: {
|
||||
type: "string",
|
||||
valueHint: "<provider.type.name>",
|
||||
description: "Resource state address (required)",
|
||||
required: true,
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Show details of a resource in agents state",
|
||||
auth: "none",
|
||||
usageArgs: "--address <provider.type.name> [--file <path>]",
|
||||
flags: STATE_SHOW_FLAGS,
|
||||
exampleArgs: ["--address bailian.agent.assistant"],
|
||||
notes: OFFLINE_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
const found = await withAgentErrors(() =>
|
||||
withStdoutProtected(async () => {
|
||||
const runtime = await buildAgentRuntime(ctx, file, {
|
||||
credentials: "none",
|
||||
});
|
||||
const parsed = parseStateAddress(flags.address, {
|
||||
requireProvider: false,
|
||||
});
|
||||
return runtime.state.findResource(parsed);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!found) {
|
||||
throw new BailianError(`Resource not found: ${flags.address}`, ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
if (format === "json") emitResult(found, format);
|
||||
else emitBare(JSON.stringify(found, null, 2));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
BailianError,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
import { validateProjectConfig } from "@openagentpack/sdk";
|
||||
import { OFFLINE_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts";
|
||||
import { withAgentErrors } from "./_engine/errors.ts";
|
||||
|
||||
const VALIDATE_FLAGS = {
|
||||
file: {
|
||||
type: "string",
|
||||
valueHint: "<path>",
|
||||
description: "Config file path (default: agents.yaml)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Validate an agents.yaml configuration (offline)",
|
||||
auth: "none",
|
||||
usageArgs: "[--file <path>]",
|
||||
flags: VALIDATE_FLAGS,
|
||||
exampleArgs: ["", "--file agents.yaml"],
|
||||
notes: OFFLINE_NOTE,
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
const file = flags.file ?? "agents.yaml";
|
||||
|
||||
const diagnostics = await withAgentErrors(async () => {
|
||||
const { config } = await resolveAgentProjectConfig(ctx, file, {
|
||||
credentials: "none",
|
||||
});
|
||||
return validateProjectConfig(config);
|
||||
});
|
||||
|
||||
const errorCount = diagnostics.filter((diag) => diag.severity === "error").length;
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ valid: errorCount === 0, diagnostics }, format);
|
||||
} else {
|
||||
for (const diag of diagnostics) {
|
||||
const where = diag.resource ? ` (${diag.resource.type}.${diag.resource.name})` : "";
|
||||
emitBare(`[${diag.severity}] ${diag.message}${where}`);
|
||||
}
|
||||
if (errorCount === 0) emitBare("Configuration is valid.");
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
throw new BailianError(`Validation failed with ${errorCount} error(s).`, ExitCode.GENERAL);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BailianError } from "bailian-cli-core";
|
||||
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
|
||||
|
||||
/** Detect MCP-not-activated / invalid 404 errors (CLI-wrapped server message). */
|
||||
export function isMcpNotActivated(error: unknown): boolean {
|
||||
if (!(error instanceof BailianError)) return false;
|
||||
const message = error.message;
|
||||
if (!/MCP request failed:\s*404\b/i.test(message)) return false;
|
||||
return /未开通|MCP不存在|MCP_IS_INVALID/i.test(message);
|
||||
}
|
||||
|
||||
/** Activation hint; URL from runtime/urls.ts. */
|
||||
export function mcpActivateHint(serverCode: string): string {
|
||||
const lines = [
|
||||
`Activate (or re-activate) the ${serverCode} MCP in the Bailian MCP marketplace, then retry.`,
|
||||
];
|
||||
if (serverCode === "WebSearch") {
|
||||
lines.push(
|
||||
"If it was previously on SSE, cancel and activate again to upgrade to Streamable HTTP.",
|
||||
);
|
||||
}
|
||||
lines.push(`Open: ${mcpMarketplaceDetailPage(serverCode)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* For not-activated errors, keep the original message / exitCode and append a hint only.
|
||||
* Do not replace the server error message.
|
||||
*/
|
||||
export function rethrowWithMcpActivateHint(error: unknown, serverCode: string): never {
|
||||
if (isMcpNotActivated(error) && error instanceof BailianError && !error.hint) {
|
||||
throw new BailianError(error.message, error.exitCode, mcpActivateHint(serverCode), {
|
||||
cause: error,
|
||||
api: error.api,
|
||||
rawResponse: error.rawResponse,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ParsedFlags,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { rethrowWithMcpActivateHint } from "./activate-hint.ts";
|
||||
|
||||
const CALL_FLAGS = {
|
||||
target: {
|
||||
@@ -130,14 +131,21 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const client = ctx.client.mcp(url);
|
||||
await client.initialize();
|
||||
const result = await client.callTool(toolName, toolArgs);
|
||||
try {
|
||||
await client.initialize();
|
||||
const result = await client.callTool(toolName, toolArgs);
|
||||
|
||||
if (result.isError) {
|
||||
const errText = result.content.map((c) => c.text || "").join("\n");
|
||||
throw new BailianError(`Tool error: ${errText}`);
|
||||
if (result.isError) {
|
||||
const errText = result.content.map((c) => c.text || "").join("\n");
|
||||
throw new BailianError(`Tool error: ${errText}`);
|
||||
}
|
||||
|
||||
emitResult(result, format);
|
||||
} catch (error) {
|
||||
if (!flags.url) {
|
||||
rethrowWithMcpActivateHint(error, serverCode);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
emitResult(result, format);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineCommand, bailianMcpPath, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { rethrowWithMcpActivateHint } from "./activate-hint.ts";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List tools exposed by an MCP server (tools/list)",
|
||||
@@ -36,8 +37,15 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const client = ctx.client.mcp(url);
|
||||
await client.initialize();
|
||||
const tools = await client.listTools();
|
||||
emitResult({ server: code, url, tools }, format);
|
||||
try {
|
||||
await client.initialize();
|
||||
const tools = await client.listTools();
|
||||
emitResult({ server: code, url, tools }, format);
|
||||
} catch (error) {
|
||||
if (!flags.url) {
|
||||
rethrowWithMcpActivateHint(error, code);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
isMcpNotActivated,
|
||||
mcpActivateHint,
|
||||
rethrowWithMcpActivateHint,
|
||||
} from "../mcp/activate-hint.ts";
|
||||
|
||||
/** Detect WebSearch MCP not-activated / invalid 404 errors. */
|
||||
export const isWebSearchMcpNotActivated = isMcpNotActivated;
|
||||
|
||||
/** WebSearch activation hint. */
|
||||
export function webSearchActivateHint(): string {
|
||||
return mcpActivateHint("WebSearch");
|
||||
}
|
||||
|
||||
/** Keep the original message; append a hint for WebSearch not-activated errors. */
|
||||
export function rethrowWithWebSearchActivateHint(error: unknown): never {
|
||||
rethrowWithMcpActivateHint(error, "WebSearch");
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
mcpWebSearchPath,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { createSpinner } from "bailian-cli-runtime";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { createSpinner, emitResult } from "bailian-cli-runtime";
|
||||
import { rethrowWithWebSearchActivateHint } from "./web-activate-hint.ts";
|
||||
|
||||
const WEB_SEARCH_FLAGS = {
|
||||
query: { type: "string", valueHint: "<text>", description: "Search query text" },
|
||||
@@ -41,11 +41,14 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ctx.client.mcp(mcpWebSearchPath());
|
||||
await client.initialize();
|
||||
const tools = await client.listTools();
|
||||
|
||||
emitResult({ tools }, format);
|
||||
try {
|
||||
const client = ctx.client.mcp(mcpWebSearchPath());
|
||||
await client.initialize();
|
||||
const tools = await client.listTools();
|
||||
emitResult({ tools }, format);
|
||||
} catch (error) {
|
||||
rethrowWithWebSearchActivateHint(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ export default defineCommand({
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop("Failed.");
|
||||
throw error;
|
||||
rethrowWithWebSearchActivateHint(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -149,11 +149,6 @@ export default defineCommand({
|
||||
if (flags.thinkingBudget !== undefined) {
|
||||
body.thinking_budget = flags.thinkingBudget;
|
||||
}
|
||||
} else if (!shouldStream) {
|
||||
// DashScope qwen3 models default to enable_thinking=true server-side, but
|
||||
// non-streaming calls require it to be explicitly false. Stream calls
|
||||
// support thinking, so leave the field unset there (server handles it).
|
||||
body.enable_thinking = false;
|
||||
}
|
||||
|
||||
if (flags.tool) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,24 @@ 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 managedAgentInit } from "./commands/managed-agent/init.ts";
|
||||
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
|
||||
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
|
||||
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
|
||||
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
|
||||
export { default as managedAgentStateList } from "./commands/managed-agent/state-list.ts";
|
||||
export { default as managedAgentStateShow } from "./commands/managed-agent/state-show.ts";
|
||||
export { default as managedAgentStateRm } from "./commands/managed-agent/state-rm.ts";
|
||||
export { default as managedAgentStateImport } from "./commands/managed-agent/state-import.ts";
|
||||
export { default as managedAgentSessionCreate } from "./commands/managed-agent/session-create.ts";
|
||||
export { default as managedAgentSessionList } from "./commands/managed-agent/session-list.ts";
|
||||
export { default as managedAgentSessionGet } from "./commands/managed-agent/session-get.ts";
|
||||
export { default as managedAgentSessionDelete } from "./commands/managed-agent/session-delete.ts";
|
||||
export { default as managedAgentSessionRun } from "./commands/managed-agent/session-run.ts";
|
||||
export { default as managedAgentSessionSend } from "./commands/managed-agent/session-send.ts";
|
||||
export { default as managedAgentSessionEvents } from "./commands/managed-agent/session-events.ts";
|
||||
export { default as managedAgentSkillList } from "./commands/managed-agent/skill-list.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,159 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { decodeTokenPlanKey } from "../src/commands/config/agent/decode-key.ts";
|
||||
|
||||
/**
|
||||
* decode-key 单元测试:在测试内移植前端 encodeTokenPlanKey 参考实现
|
||||
* (bailian-tokenplan encode-token-plan-key.ts),做 encode → decode round-trip,
|
||||
* 保证 CLI 解码与前端编码逐位互逆。
|
||||
*/
|
||||
|
||||
const TOKEN_PREFIX = "o1_";
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
|
||||
const ALPHABET_SIZE = ALPHABET.length;
|
||||
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
|
||||
const CHECKSUM_LENGTH = 6;
|
||||
const FEISTEL_ROUNDS = 8;
|
||||
|
||||
function toDigits(value: string): number[] {
|
||||
return value.split("").map((character) => {
|
||||
const digit = ALPHABET_INDEX.get(character);
|
||||
if (digit === undefined) throw new Error("bad char");
|
||||
return digit;
|
||||
});
|
||||
}
|
||||
|
||||
function fromDigits(digits: number[]): string {
|
||||
return digits.map((digit) => ALPHABET[digit]).join("");
|
||||
}
|
||||
|
||||
function mixState(state: number, value: number): number {
|
||||
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
function nextState(state: number): number {
|
||||
let next = state >>> 0;
|
||||
next ^= next << 13;
|
||||
next ^= next >>> 17;
|
||||
next ^= next << 5;
|
||||
return next >>> 0;
|
||||
}
|
||||
|
||||
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
|
||||
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
|
||||
state = mixState(state, right.length);
|
||||
state = mixState(state, length);
|
||||
for (const character of salt) {
|
||||
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
|
||||
}
|
||||
for (const digit of right) {
|
||||
state = mixState(state, digit + 1);
|
||||
}
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 0x85ebca6b) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 0xc2b2ae35) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = state >>> 0 || 0x6d2b79f5;
|
||||
|
||||
const mask: number[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
|
||||
state = nextState(state);
|
||||
mask.push(state % ALPHABET_SIZE);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function obfuscatePayload(apiKey: string, salt: string): string {
|
||||
const digits = toDigits(apiKey);
|
||||
const midpoint = Math.floor(digits.length / 2);
|
||||
let left = digits.slice(0, midpoint);
|
||||
let right = digits.slice(midpoint);
|
||||
|
||||
for (let round = 0; round < FEISTEL_ROUNDS; round += 1) {
|
||||
const mask = createRoundMask(right, salt, round, left.length);
|
||||
const nextRight = left.map((digit, index) => (digit + mask[index]) % ALPHABET_SIZE);
|
||||
left = right;
|
||||
right = nextRight;
|
||||
}
|
||||
return fromDigits([...left, ...right]);
|
||||
}
|
||||
|
||||
function crc32(value: string): number {
|
||||
let checksum = 0xffffffff;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
checksum ^= value.charCodeAt(index);
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
const mask = -(checksum & 1);
|
||||
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
|
||||
}
|
||||
}
|
||||
return (checksum ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function encodeBase65Number(value: number, length: number): string {
|
||||
let remaining = value >>> 0;
|
||||
const encoded = Array<string>(length).fill(ALPHABET[0]);
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
|
||||
remaining = Math.floor(remaining / ALPHABET_SIZE);
|
||||
}
|
||||
return encoded.join("");
|
||||
}
|
||||
|
||||
/** 前端 encodeTokenPlanKey 的测试内移植(固定 salt)。 */
|
||||
function encodeTokenPlanKey(apiKey: string, salt: string): string {
|
||||
const payload = obfuscatePayload(apiKey, salt);
|
||||
const checksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
|
||||
return TOKEN_PREFIX + salt + payload + checksum;
|
||||
}
|
||||
|
||||
describe("config agent decode-key", () => {
|
||||
test("encode → decode round-trip 还原原始 apiKey", () => {
|
||||
const samples = [
|
||||
"sk-1234567890abcdef",
|
||||
"sk-sp-H.PML.Ns85.MEUCIFHbYk4yBBWLGegORHfWZGB5DdSEs6ms3AwyMsuTOk0CAiEAlOwrUO6dz6IYPUlJ4gK7u6kjStkythgxWaVP5B28ly0",
|
||||
"a",
|
||||
"A-b_c.9",
|
||||
];
|
||||
const salts = ["AbC123", "zzzzzz", "0.-_Zq", "AAAAAA"];
|
||||
for (const apiKey of samples) {
|
||||
for (const salt of salts) {
|
||||
expect(decodeTokenPlanKey(encodeTokenPlanKey(apiKey, salt))).toBe(apiKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("固定 salt 的确定性:相同输入产出相同 token 且可解码", () => {
|
||||
const tokenA = encodeTokenPlanKey("sk-fixed-key", "S4ltS4");
|
||||
const tokenB = encodeTokenPlanKey("sk-fixed-key", "S4ltS4");
|
||||
expect(tokenA).toBe(tokenB);
|
||||
expect(decodeTokenPlanKey(tokenA)).toBe("sk-fixed-key");
|
||||
});
|
||||
|
||||
test("篡改 checksum 抛错", () => {
|
||||
const token = encodeTokenPlanKey("sk-checksum-test", "AbC123");
|
||||
const flippedTail = token.slice(-1) === "A" ? "B" : "A";
|
||||
const tampered = token.slice(0, -1) + flippedTail;
|
||||
expect(() => decodeTokenPlanKey(tampered)).toThrow(/Invalid obfuscated API key/);
|
||||
});
|
||||
|
||||
test("篡改 salt 抛错(payload 解出与 checksum 不符)", () => {
|
||||
const token = encodeTokenPlanKey("sk-salt-test", "AbC123");
|
||||
const body = token.slice(TOKEN_PREFIX.length);
|
||||
const flippedSaltHead = body[0] === "A" ? "B" : "A";
|
||||
const tampered = TOKEN_PREFIX + flippedSaltHead + body.slice(1);
|
||||
expect(() => decodeTokenPlanKey(tampered)).toThrow(/Invalid obfuscated API key/);
|
||||
});
|
||||
|
||||
test("非法前缀 / 非法字符 / 过短 token 抛错", () => {
|
||||
expect(() => decodeTokenPlanKey("x1_AbC123payloadAAAAAA")).toThrow(
|
||||
/Invalid obfuscated API key/,
|
||||
);
|
||||
expect(() => decodeTokenPlanKey("o1_AbC123pay!oadAAAAAA")).toThrow(
|
||||
/Invalid obfuscated API key/,
|
||||
);
|
||||
expect(() => decodeTokenPlanKey("o1_short")).toThrow(/Invalid obfuscated API key/);
|
||||
expect(() => decodeTokenPlanKey("")).toThrow(/Invalid obfuscated API key/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
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 { resolveRegionBaseUrl } from "../src/commands/config/agent/writers/utils.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 键与旧的 ANTHROPIC_API_KEY,验证合并保留 / 旧键清理
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({
|
||||
env: { KEEP_ME: "1", ANTHROPIC_API_KEY: "sk-stale" },
|
||||
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_API_KEY).toBeUndefined();
|
||||
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("claude-code 尊重 CLAUDE_CONFIG_DIR", () => {
|
||||
const customDir = join(home, "custom-claude");
|
||||
process.env.CLAUDE_CONFIG_DIR = customDir;
|
||||
try {
|
||||
claudeCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const settings = JSON.parse(readFileSync(join(customDir, "settings.json"), "utf8"));
|
||||
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
|
||||
} finally {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
test("claude-code 将 compatible-mode URL 改写为 apps/anthropic", () => {
|
||||
const tokenPlanOpenAi = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
|
||||
const summary = claudeCode.write({
|
||||
baseUrl: tokenPlanOpenAi,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3.8-max-preview",
|
||||
});
|
||||
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
expect(summary.warnings?.some((warning) => warning.includes("Rewrote base URL"))).toBe(true);
|
||||
});
|
||||
|
||||
test("claude-code 保留已有分层模型,不整表覆盖", () => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.6-flash",
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: "qwen3.7-max",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
claudeCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3.8-max-preview",
|
||||
});
|
||||
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
|
||||
expect(env.ANTHROPIC_MODEL).toBe("qwen3.8-max-preview");
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3.6-flash");
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3.7-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3.8-max-preview");
|
||||
});
|
||||
|
||||
test("claude-code 拒绝无法改写为 Anthropic 的 base URL", () => {
|
||||
expect(() =>
|
||||
claudeCode.write({
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3-max",
|
||||
}),
|
||||
).toThrow(/Anthropic-compatible base URL/);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议(官方 v3 结构)", () => {
|
||||
qwenCode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-q",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
expect(settings.$version).toBe(3);
|
||||
const security = settings.security as { auth: Record<string, unknown> };
|
||||
// security.auth 携带 selectedType 以及凭证兜底(apiKey/baseUrl),
|
||||
// 避免系统 OPENAI_API_KEY 抢占
|
||||
expect(security.auth).toEqual({
|
||||
selectedType: "openai",
|
||||
apiKey: "sk-q",
|
||||
baseUrl: OAI_URL,
|
||||
});
|
||||
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-q");
|
||||
// model.name 必须与 baseUrl 一同写入(同 id provider 消歧契约)
|
||||
expect(settings.model).toEqual({
|
||||
name: "qwen3-coder-plus",
|
||||
baseUrl: OAI_URL,
|
||||
});
|
||||
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
|
||||
// name 是模型显示名(非 provider 品牌常量),品牌只在 envKey 里
|
||||
expect(providers.openai[0]).toMatchObject({
|
||||
id: "qwen3-coder-plus",
|
||||
name: "[Bailian] qwen3-coder-plus",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "DASHSCOPE_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
test("qwen-code upsert 时治愈旧的 bailian-cli name 但保留用户自定义 name", () => {
|
||||
mkdirSync(join(home, ".qwen"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".qwen", "settings.json"),
|
||||
JSON.stringify({
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: "qwen3-coder-plus",
|
||||
name: "bailian-cli",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "DASHSCOPE_API_KEY",
|
||||
},
|
||||
{
|
||||
id: "my-model",
|
||||
name: "My Custom",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "DASHSCOPE_API_KEY",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 旧 sentinel 被治愈为显示名
|
||||
qwenCode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-q",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
// 用户自定义 name 不被覆盖
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "my-model" });
|
||||
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const entries = (settings.modelProviders as Record<string, Array<Record<string, unknown>>>)
|
||||
.openai;
|
||||
const healed = entries.find((entry) => entry.id === "qwen3-coder-plus")!;
|
||||
expect(healed.name).toBe("[Bailian] qwen3-coder-plus");
|
||||
expect(healed.envKey).toBe("DASHSCOPE_API_KEY");
|
||||
const custom = entries.find((entry) => entry.id === "my-model")!;
|
||||
expect(custom.name).toBe("My Custom");
|
||||
});
|
||||
|
||||
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 对自有 provider 项按 id 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);
|
||||
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-2");
|
||||
});
|
||||
|
||||
test("qwen-code 不劫持已有 Token Plan 同 id 条目的 name/envKey", () => {
|
||||
mkdirSync(join(home, ".qwen"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".qwen", "settings.json"),
|
||||
JSON.stringify({
|
||||
env: { BAILIAN_TOKEN_PLAN_API_KEY: "sk-token-plan" },
|
||||
modelProviders: {
|
||||
openai: [
|
||||
{
|
||||
id: "qwen3.8-max-preview",
|
||||
name: "[Token Plan 个人版] qwen3.8-max-preview",
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
envKey: "BAILIAN_TOKEN_PLAN_API_KEY",
|
||||
generationConfig: { extra_body: { enable_thinking: true } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const tokenPlanUrl = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
|
||||
const summary = qwenCode.write({
|
||||
baseUrl: tokenPlanUrl,
|
||||
apiKey: "sk-bailian",
|
||||
model: "qwen3.8-max-preview",
|
||||
});
|
||||
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const openaiEntries = (
|
||||
settings.modelProviders as Record<string, Array<Record<string, unknown>>>
|
||||
).openai;
|
||||
expect(openaiEntries).toHaveLength(1);
|
||||
expect(openaiEntries[0]).toMatchObject({
|
||||
id: "qwen3.8-max-preview",
|
||||
name: "[Token Plan 个人版] qwen3.8-max-preview",
|
||||
envKey: "BAILIAN_TOKEN_PLAN_API_KEY",
|
||||
generationConfig: { extra_body: { enable_thinking: true } },
|
||||
});
|
||||
expect((settings.env as Record<string, string>).BAILIAN_TOKEN_PLAN_API_KEY).toBe(
|
||||
"sk-token-plan",
|
||||
);
|
||||
expect((settings.env as Record<string, string>).DASHSCOPE_API_KEY).toBe("sk-bailian");
|
||||
expect(summary.warnings?.some((warning) => warning.includes("already exists"))).toBe(true);
|
||||
});
|
||||
|
||||
test("qwen-code 在进程环境变量覆盖 settings.env 时给出警告", () => {
|
||||
const previous = process.env.DASHSCOPE_API_KEY;
|
||||
process.env.DASHSCOPE_API_KEY = "sk-from-shell";
|
||||
try {
|
||||
const summary = qwenCode.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-from-settings",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
expect(summary.warnings?.some((warning) => warning.includes("overrides settings.json"))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DASHSCOPE_API_KEY;
|
||||
else process.env.DASHSCOPE_API_KEY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("opencode 容忍 JSONC(注释与尾逗号)", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".config", "opencode", "opencode.json"),
|
||||
[
|
||||
"{",
|
||||
" // user comment",
|
||||
' "provider": {',
|
||||
' "other": { "name": "Other" }, // inline comment',
|
||||
" },",
|
||||
" /* block */",
|
||||
' "theme": "dark",',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
const config = readJsonAt(".config", "opencode", "opencode.json");
|
||||
expect(config.theme).toBe("dark");
|
||||
const provider = config.provider as Record<string, unknown>;
|
||||
expect(provider.other).toBeDefined();
|
||||
expect(provider["bailian-cli"]).toBeDefined();
|
||||
});
|
||||
|
||||
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,并登记 defaults.models", () => {
|
||||
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");
|
||||
const entry = (bailian.models as Array<Record<string, unknown>>)[0];
|
||||
expect(entry.id).toBe("qwen3-coder-plus");
|
||||
expect(entry.contextWindow).toBe(256000);
|
||||
expect(entry.cost).toEqual({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
const agents = config.agents as {
|
||||
defaults: { model: { primary: string }; models: Record<string, unknown> };
|
||||
};
|
||||
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
|
||||
expect(agents.defaults.models["bailian-cli/qwen3-coder-plus"]).toEqual({});
|
||||
|
||||
// --context-window 覆盖默认值;anthropic 端点用 anthropic-messages
|
||||
openclaw.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-c",
|
||||
model: "qwen3-max",
|
||||
contextWindow: 1000000,
|
||||
});
|
||||
const config2 = readJsonAt(".openclaw", "openclaw.json");
|
||||
const providers2 = (config2.models as Record<string, unknown>).providers as Record<
|
||||
string,
|
||||
{ api: string; models: Array<Record<string, unknown>> }
|
||||
>;
|
||||
expect(providers2["bailian-cli"].api).toBe("anthropic-messages");
|
||||
expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000);
|
||||
expect(
|
||||
(config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary,
|
||||
).toBe("bailian-cli/qwen3-max");
|
||||
});
|
||||
|
||||
test("openclaw 不抢占已有 token-plan primary", () => {
|
||||
mkdirSync(join(home, ".openclaw"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".openclaw", "openclaw.json"),
|
||||
JSON.stringify({
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"bailian-token-plan": {
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
|
||||
apiKey: "sk-token-plan",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "qwen3.8-max-preview", name: "qwen3.8-max-preview" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "bailian-token-plan/qwen3.8-max-preview" },
|
||||
models: { "bailian-token-plan/qwen3.8-max-preview": {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = openclaw.write({
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-bailian",
|
||||
model: "qwen3.8-max-preview",
|
||||
});
|
||||
|
||||
const config = readJsonAt(".openclaw", "openclaw.json");
|
||||
const agents = config.agents as {
|
||||
defaults: { model: { primary: string }; models: Record<string, unknown> };
|
||||
};
|
||||
expect(agents.defaults.model.primary).toBe("bailian-token-plan/qwen3.8-max-preview");
|
||||
expect(agents.defaults.models["bailian-cli/qwen3.8-max-preview"]).toEqual({});
|
||||
expect(
|
||||
(config.models as { providers: Record<string, unknown> }).providers["bailian-token-plan"],
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(config.models as { providers: Record<string, unknown> }).providers["bailian-cli"],
|
||||
).toBeDefined();
|
||||
expect(summary.warnings?.some((warning) => warning.includes("Left existing primary"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("hermes 写入官方扁平 model.* 结构,保留其它顶层键", () => {
|
||||
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"));
|
||||
// OpenAI 兼容端点:按官方文档省略 api_mode;无关顶层键不受影响
|
||||
expect(config.model).toEqual({
|
||||
default: "qwen3-coder-plus",
|
||||
provider: "custom",
|
||||
base_url: OAI_URL,
|
||||
api_key: "sk-h",
|
||||
});
|
||||
expect(config.custom_providers).toHaveLength(1);
|
||||
|
||||
// anthropic 端点:必须带 api_mode = anthropic_messages
|
||||
hermes.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-h",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
expect(config2.model).toEqual({
|
||||
default: "qwen3-max",
|
||||
provider: "custom",
|
||||
base_url: ANTHROPIC_URL,
|
||||
api_key: "sk-h",
|
||||
api_mode: "anthropic_messages",
|
||||
});
|
||||
});
|
||||
|
||||
test("codex 写入 config.toml 与 auth.json(官方 env_key 结构,合并保留)", () => {
|
||||
// 预置 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" }));
|
||||
|
||||
const summary = codex.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-x",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
// 默认路径无警告
|
||||
expect(summary.warnings).toBeUndefined();
|
||||
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_providers.bailian-cli]");
|
||||
expect(toml).toContain(`base_url = "${OAI_URL}"`);
|
||||
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
|
||||
// 未传 --wire-api 时默认 responses(新版 Codex 已不支持 chat)
|
||||
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");
|
||||
|
||||
// --wire-api chat:仅旧版 Codex <= 0.80.0 可用,附带警告
|
||||
const summary2 = codex.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-x",
|
||||
model: "glm-5",
|
||||
wireApi: "chat",
|
||||
});
|
||||
expect(summary2.warnings?.some((warning) => warning.includes("0.80.0"))).toBe(true);
|
||||
const toml2 = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml2).toContain('wire_api = "chat"');
|
||||
expect(toml2).toContain('model = "glm-5"');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("resolveRegionBaseUrl 将 region 转为 Token Plan compatible-mode URL", () => {
|
||||
expect(resolveRegionBaseUrl("cn-beijing")).toBe(
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
expect(resolveRegionBaseUrl("ap-southeast-1")).toBe(
|
||||
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveRegionBaseUrl 拒绝非法 region", () => {
|
||||
expect(() => resolveRegionBaseUrl("cn beijing")).toThrow(/Invalid --region/);
|
||||
expect(() => resolveRegionBaseUrl("CN-Beijing")).toThrow(/Invalid --region/);
|
||||
expect(() => resolveRegionBaseUrl("")).toThrow(/Invalid --region/);
|
||||
});
|
||||
});
|
||||
@@ -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 写命名 profile(timeout 强制为 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
type ApiKeyCredential,
|
||||
Client,
|
||||
ExitCode,
|
||||
type Identity,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
import {
|
||||
assertProviderCredentials,
|
||||
type CredentialHost,
|
||||
injectProviderCredentials,
|
||||
prepareProviderEnv,
|
||||
scrubCredentialEnv,
|
||||
} from "../src/commands/managed-agent/_engine/credentials.ts";
|
||||
|
||||
/**
|
||||
* 凭证内存注入管道:injectProviderCredentials 把 authStage 解析进 Client 的凭证
|
||||
* 权威覆写 bailian 配置块(不落 env),scrubCredentialEnv 清空所有凭证 env,
|
||||
* assertProviderCredentials 对任一已声明 provider 的空 key 给 CLI 权威 AUTH
|
||||
* 错误(离线命令跳过断言)。用快照隔离凭证 env。
|
||||
*/
|
||||
const TRACKED_ENV = [
|
||||
"DASHSCOPE_API_KEY",
|
||||
"BAILIAN_API_KEY",
|
||||
"BAILIAN_BASE_URL",
|
||||
"BAILIAN_WORKSPACE_ID",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_API_KEY",
|
||||
"ARK_API_KEY",
|
||||
"QODER_PAT",
|
||||
"QODER_API_KEY",
|
||||
"AGENTS_CONFIG_PATH",
|
||||
"AGENTS_PROVIDER",
|
||||
];
|
||||
|
||||
let envSnapshot: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = Object.fromEntries(TRACKED_ENV.map((key) => [key, process.env[key]]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of TRACKED_ENV) {
|
||||
const value = envSnapshot[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const identity: Identity = {
|
||||
binName: "bl",
|
||||
version: "0.0.0-test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli",
|
||||
};
|
||||
|
||||
function makeHost(options: { apiCred?: ApiKeyCredential; workspaceId?: string }): CredentialHost {
|
||||
const settings = { workspaceId: options.workspaceId } as Settings;
|
||||
return {
|
||||
settings,
|
||||
client: new Client({
|
||||
identity,
|
||||
settings,
|
||||
baseUrl: options.apiCred?.baseUrl ?? "https://dashscope.aliyuncs.com",
|
||||
apiCred: options.apiCred,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function bailianCred(
|
||||
token = "sk-auth-chain",
|
||||
baseUrl = "https://dashscope.aliyuncs.com",
|
||||
): ApiKeyCredential {
|
||||
return { token, baseUrl, source: "config" };
|
||||
}
|
||||
|
||||
test("inject:bailian api_key 无条件覆盖(含 yaml 字面量),base_url 空则拼 agentstudio 后缀", () => {
|
||||
const providers = { bailian: { api_key: "literal-from-yaml", base_url: "" } };
|
||||
injectProviderCredentials(providers, makeHost({ apiCred: bailianCred() }));
|
||||
expect(providers.bailian.api_key).toBe("sk-auth-chain");
|
||||
expect(providers.bailian.base_url).toBe("https://dashscope.aliyuncs.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:base_url 已带后缀不重复拼;非空字面量 base_url 保留", () => {
|
||||
const withSuffix = { bailian: { api_key: "", base_url: "" } };
|
||||
injectProviderCredentials(
|
||||
withSuffix,
|
||||
makeHost({
|
||||
apiCred: bailianCred("t", "https://x.maas.aliyuncs.com/api/v1/agentstudio"),
|
||||
}),
|
||||
);
|
||||
expect(withSuffix.bailian.base_url).toBe("https://x.maas.aliyuncs.com/api/v1/agentstudio");
|
||||
|
||||
const literal = {
|
||||
bailian: {
|
||||
api_key: "",
|
||||
base_url: "https://custom.example.com/api/v1/agentstudio",
|
||||
},
|
||||
};
|
||||
injectProviderCredentials(literal, makeHost({ apiCred: bailianCred() }));
|
||||
expect(literal.bailian.base_url).toBe("https://custom.example.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:base_url 尾斜杠被规范化,不产生双斜杠", () => {
|
||||
const providers = { bailian: { api_key: "", base_url: "" } };
|
||||
injectProviderCredentials(
|
||||
providers,
|
||||
makeHost({ apiCred: bailianCred("t", "https://dashscope.aliyuncs.com/") }),
|
||||
);
|
||||
expect(providers.bailian.base_url).toBe("https://dashscope.aliyuncs.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:已带后缀且尾斜杠的 base_url 去斜杠后原样保留", () => {
|
||||
const providers = { bailian: { api_key: "", base_url: "" } };
|
||||
injectProviderCredentials(
|
||||
providers,
|
||||
makeHost({
|
||||
apiCred: bailianCred("t", "https://x.maas.aliyuncs.com/api/v1/agentstudio/"),
|
||||
}),
|
||||
);
|
||||
expect(providers.bailian.base_url).toBe("https://x.maas.aliyuncs.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:workspace_id 引用且为空时用 settings 填充;有字面量则保留", () => {
|
||||
const empty = { bailian: { api_key: "", workspace_id: "" } };
|
||||
injectProviderCredentials(
|
||||
empty,
|
||||
makeHost({ apiCred: bailianCred(), workspaceId: "ws-settings" }),
|
||||
);
|
||||
expect(empty.bailian.workspace_id).toBe("ws-settings");
|
||||
|
||||
const literal = { bailian: { api_key: "", workspace_id: "ws-yaml" } };
|
||||
injectProviderCredentials(
|
||||
literal,
|
||||
makeHost({ apiCred: bailianCred(), workspaceId: "ws-settings" }),
|
||||
);
|
||||
expect(literal.bailian.workspace_id).toBe("ws-yaml");
|
||||
});
|
||||
|
||||
test("inject:无凭证时 api_key 保持不变,base_url 仍用 client 默认域名补齐(离线/范围外 schema 可用)", () => {
|
||||
const providers = { bailian: { api_key: "", base_url: "" } };
|
||||
injectProviderCredentials(providers, makeHost({}));
|
||||
expect(providers.bailian.api_key).toBe("");
|
||||
expect(providers.bailian.base_url).toBe("https://dashscope.aliyuncs.com/api/v1/agentstudio");
|
||||
});
|
||||
|
||||
test("inject:非 bailian provider 块不被触碰", () => {
|
||||
const providers = {
|
||||
claude: { api_key: "sk-ant" },
|
||||
ark: { api_key: "ark-key" },
|
||||
};
|
||||
injectProviderCredentials(providers, makeHost({ apiCred: bailianCred() }));
|
||||
expect(providers.claude.api_key).toBe("sk-ant");
|
||||
expect(providers.ark.api_key).toBe("ark-key");
|
||||
});
|
||||
|
||||
test("assert:所有已声明 provider 的 key 非空时通过", () => {
|
||||
expect(() =>
|
||||
assertProviderCredentials({
|
||||
bailian: { api_key: "x" },
|
||||
claude: { api_key: "y" },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("assert:claude key 为空抛 AUTH 且 hint 指向 ANTHROPIC_API_KEY", () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertProviderCredentials({ claude: { api_key: "" } });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
const err = thrown as { exitCode?: number; hint?: string; message?: string };
|
||||
expect(err.exitCode).toBe(ExitCode.AUTH);
|
||||
expect(err.hint).toContain("ANTHROPIC_API_KEY");
|
||||
expect(err.message).toContain("claude");
|
||||
});
|
||||
|
||||
test("assert:bailian key 为空(dry-run/未登录)抛 AUTH 且 hint 指向 bl auth login", () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertProviderCredentials({ bailian: { api_key: "" } });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
const err = thrown as { exitCode?: number; hint?: string };
|
||||
expect(err.exitCode).toBe(ExitCode.AUTH);
|
||||
expect(err.hint).toContain("bl auth login");
|
||||
});
|
||||
|
||||
test("scrub:所有凭证 env 变量被删除", () => {
|
||||
process.env.DASHSCOPE_API_KEY = "x";
|
||||
process.env.ANTHROPIC_API_KEY = "y";
|
||||
process.env.ARK_API_KEY = "z";
|
||||
process.env.BAILIAN_BASE_URL = "u";
|
||||
process.env.QODER_PAT = "q";
|
||||
scrubCredentialEnv();
|
||||
expect(process.env.DASHSCOPE_API_KEY).toBeUndefined();
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(process.env.ARK_API_KEY).toBeUndefined();
|
||||
expect(process.env.BAILIAN_BASE_URL).toBeUndefined();
|
||||
expect(process.env.QODER_PAT).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prepare:调用后凭证变量均已定义,避免 yaml 插值抛错", () => {
|
||||
// 指向不存在的 agents 配置,隔离宿主 ~/.agents/config.json 干扰
|
||||
process.env.AGENTS_CONFIG_PATH = join(tmpdir(), "no-such-agents-config.json");
|
||||
delete process.env.ARK_API_KEY;
|
||||
delete process.env.QODER_API_KEY;
|
||||
prepareProviderEnv();
|
||||
// 契约:每个凭证变量都被占位或填充,插值不会因 undefined 抛错
|
||||
expect(process.env.ARK_API_KEY).toBeDefined();
|
||||
expect(process.env.QODER_API_KEY).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
destroyPlannedProjectResources: vi.fn(),
|
||||
planDestroyProjectContext: vi.fn(),
|
||||
}));
|
||||
|
||||
const configLoaderMocks = vi.hoisted(() => ({
|
||||
buildAgentRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@openagentpack/sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@openagentpack/sdk")>();
|
||||
return { ...actual, ...sdkMocks };
|
||||
});
|
||||
|
||||
vi.mock("../src/commands/managed-agent/_engine/config-loader.ts", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../src/commands/managed-agent/_engine/config-loader.ts")>();
|
||||
return { ...actual, ...configLoaderMocks };
|
||||
});
|
||||
|
||||
import destroyCommand from "../src/commands/managed-agent/destroy.ts";
|
||||
|
||||
const resources = [
|
||||
{
|
||||
address: { provider: "bailian", type: "agent", name: "assistant" },
|
||||
remote_id: "agent-ok",
|
||||
},
|
||||
{
|
||||
address: { provider: "bailian", type: "environment", name: "dev" },
|
||||
remote_id: "env-failed",
|
||||
},
|
||||
];
|
||||
|
||||
let stdoutChunks: string[] = [];
|
||||
let originalStdoutWrite: typeof process.stdout.write;
|
||||
let originalStderrWrite: typeof process.stderr.write;
|
||||
|
||||
beforeEach(() => {
|
||||
stdoutChunks = [];
|
||||
originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdoutChunks.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
process.stderr.write = (() => true) as typeof process.stderr.write;
|
||||
|
||||
const planned = { resources, executionContext: {} };
|
||||
configLoaderMocks.buildAgentRuntime.mockResolvedValue({});
|
||||
sdkMocks.planDestroyProjectContext.mockReturnValue(planned);
|
||||
sdkMocks.destroyPlannedProjectResources.mockResolvedValue({
|
||||
...planned,
|
||||
results: [
|
||||
{ resource: resources[0], status: "success", reason: "destroyed" },
|
||||
{
|
||||
resource: resources[1],
|
||||
status: "failed",
|
||||
reason: "failed",
|
||||
error: "provider refused deletion",
|
||||
},
|
||||
],
|
||||
destroyed: 1,
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("destroy 部分失败时输出汇总并以首个原始错误抛 GENERAL", async () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await destroyCommand.run({
|
||||
settings: { output: "json", dryRun: false },
|
||||
flags: { yes: true },
|
||||
} as never);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(BailianError);
|
||||
const mapped = thrown as BailianError;
|
||||
expect(mapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(mapped.message).toBe("provider refused deletion");
|
||||
expect(JSON.parse(stdoutChunks.join(""))).toEqual({ destroyed: 1, total: 2 });
|
||||
});
|
||||
@@ -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,245 @@ 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,
|
||||
},
|
||||
});
|
||||
|
||||
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.7-max",
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
|
||||
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 +485,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 +596,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",
|
||||
@@ -123,10 +295,67 @@ describe("e2e: config", () => {
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_text_model?: string };
|
||||
}>(stdout);
|
||||
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 +385,285 @@ 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/--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|--key|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent --api-key 与 --key 同传时报用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--key",
|
||||
"o1_AbC123kaQ9JHCXF2GepMW4oJTD7ODPw_Hx",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/mutually exclusive|--api-key/i);
|
||||
});
|
||||
|
||||
test("config agent --key 非法值时报用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--key",
|
||||
"not-an-encoded-key",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
"--dry-run",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid obfuscated API key|o1_/i);
|
||||
});
|
||||
|
||||
test("config agent --key 合法值 --dry-run 解码成功且输出脱敏", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-key-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--key",
|
||||
// encode("sk-e2e-key-placeholder", salt "AbC123") 的固定产物
|
||||
"o1_AbC123kaQ9JHCXF2GepMW4oJTD7ODPw_Hx",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ agent?: string; api_key?: string }>(stdout);
|
||||
expect(data.agent).toBe("claude-code");
|
||||
// 解码后的真实 key 不得明文出现,且脱敏值非空
|
||||
expect(stdout).not.toContain("sk-e2e-key-placeholder");
|
||||
expect(data.api_key).toBeTruthy();
|
||||
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent --region 转为 base URL,--dry-run 成功", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-region-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"qwen-code",
|
||||
"--region",
|
||||
"cn-beijing",
|
||||
"--api-key",
|
||||
"sk-region-placeholder",
|
||||
"--model",
|
||||
"qwen3.8-max-preview",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ agent?: string; base_url?: string }>(stdout);
|
||||
expect(data.agent).toBe("qwen-code");
|
||||
expect(data.base_url).toBe(
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent --base-url 与 --region 同传时报用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"qwen-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--region",
|
||||
"cn-beijing",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/mutually exclusive|--base-url|--region/i);
|
||||
});
|
||||
|
||||
test("config agent 既缺 --base-url 又缺 --region 时报用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"qwen-code",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--base-url|--region|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.json(官方 env_key 结构)", 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('env_key = "OPENAI_API_KEY"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
// 默认即 responses(新版 Codex 已不支持 chat)
|
||||
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 写入官方扁平 model.* 结构", 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("default: qwen3-coder-plus");
|
||||
expect(yamlText).toContain("provider: custom");
|
||||
expect(yamlText).toContain("base_url: https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
expect(yamlText).toContain("api_key: sk-hermes-placeholder");
|
||||
// OpenAI 兼容端点不写 api_mode
|
||||
expect(yamlText).not.toContain("api_mode");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
version: "1"
|
||||
|
||||
providers:
|
||||
bailian:
|
||||
api_key: ${DASHSCOPE_API_KEY}
|
||||
|
||||
agents: "not-a-map"
|
||||
@@ -0,0 +1,28 @@
|
||||
version: "1"
|
||||
|
||||
providers:
|
||||
bailian:
|
||||
api_key: ${DASHSCOPE_API_KEY}
|
||||
base_url: ${BAILIAN_BASE_URL}
|
||||
claude:
|
||||
api_key: ${ANTHROPIC_API_KEY}
|
||||
|
||||
defaults:
|
||||
provider: all
|
||||
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
type: cloud
|
||||
networking:
|
||||
type: unrestricted
|
||||
|
||||
agents:
|
||||
assistant:
|
||||
description: "E2E multi-provider fixture"
|
||||
model:
|
||||
bailian: qwen3.7-max
|
||||
claude: claude-sonnet-4-6
|
||||
instructions: |
|
||||
You are a helpful assistant.
|
||||
environment: dev
|
||||
@@ -0,0 +1,24 @@
|
||||
version: "1"
|
||||
|
||||
providers:
|
||||
bailian:
|
||||
api_key: ${DASHSCOPE_API_KEY}
|
||||
base_url: ${BAILIAN_BASE_URL}
|
||||
|
||||
defaults:
|
||||
provider: bailian
|
||||
|
||||
environments:
|
||||
dev:
|
||||
config:
|
||||
type: cloud
|
||||
networking:
|
||||
type: unrestricted
|
||||
|
||||
agents:
|
||||
assistant:
|
||||
description: "E2E auth-chain fixture"
|
||||
model: qwen3.7-max
|
||||
instructions: |
|
||||
You are a helpful assistant.
|
||||
environment: dev
|
||||
@@ -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,127 @@ 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>",
|
||||
);
|
||||
});
|
||||
|
||||
test("wan2.5-i2i-preview dry-run 走 prompt+images", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"edit",
|
||||
"--model",
|
||||
"wan2.5-i2i-preview",
|
||||
"--image",
|
||||
"https://example.com/source.png",
|
||||
"--prompt",
|
||||
"Place on a table",
|
||||
"--size",
|
||||
"1:1",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
mode?: string;
|
||||
path?: string;
|
||||
request?: {
|
||||
input?: { prompt?: string; images?: string[]; messages?: unknown };
|
||||
parameters?: { size?: string };
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.mode).toBe("async");
|
||||
expect(data.path).toBe("/api/v1/services/aigc/image2image/image-synthesis");
|
||||
expect(data.request?.input?.prompt).toBe("Place on a table");
|
||||
expect(data.request?.input?.images).toEqual(["https://example.com/source.png"]);
|
||||
expect(data.request?.input?.messages).toBeUndefined();
|
||||
expect(data.request?.parameters?.size).toBe("1280*1280");
|
||||
});
|
||||
|
||||
test("wanx2.1-imageedit dry-run 走 function + base_image_url", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"edit",
|
||||
"--model",
|
||||
"wanx2.1-imageedit",
|
||||
"--image",
|
||||
"https://example.com/source.png",
|
||||
"--prompt",
|
||||
"转换成绘本风格",
|
||||
"--function",
|
||||
"stylization_all",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
mode?: string;
|
||||
path?: string;
|
||||
request?: {
|
||||
input?: {
|
||||
function?: string;
|
||||
prompt?: string;
|
||||
base_image_url?: string;
|
||||
images?: unknown;
|
||||
messages?: unknown;
|
||||
};
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.mode).toBe("async");
|
||||
expect(data.path).toBe("/api/v1/services/aigc/image2image/image-synthesis");
|
||||
expect(data.request?.input?.function).toBe("stylization_all");
|
||||
expect(data.request?.input?.prompt).toBe("转换成绘本风格");
|
||||
expect(data.request?.input?.base_image_url).toBe("https://example.com/source.png");
|
||||
expect(data.request?.input?.images).toBeUndefined();
|
||||
expect(data.request?.input?.messages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user