mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d61afc1d5 | |||
| 7a870ec417 | |||
| 1c38c381e5 | |||
| ed81178ad7 | |||
| 7e23ba00fb | |||
| 2c53b0692b | |||
| adc89f635d | |||
| afa43a42b9 | |||
| bbf45a5961 | |||
| 3988e701e1 | |||
| 96744e3328 | |||
| 3b7993e854 | |||
| be6ddb6126 | |||
| 25ac5c9c84 | |||
| 8ef91fe395 | |||
| 5d9e22de8f | |||
| 20e3555b84 | |||
| 81fa5b567c | |||
| df987ad536 | |||
| 4c4e7afb83 | |||
| 17c52fb86f | |||
| 634d7045c6 | |||
| eadd92327f | |||
| 5a58f56b06 | |||
| 7ad14a79b9 | |||
| 8211268bd8 | |||
| 7319f6d1ce | |||
| 2dce9fe093 | |||
| 9819eb6ddc | |||
| 58252911a8 | |||
| a03ee0c72c | |||
| 05860b3bdd | |||
| 63ee5aaec3 | |||
| 93c9149e45 | |||
| 6f9e006fef | |||
| e22058b0f7 | |||
| 0221e35803 | |||
| 36ebd63716 | |||
| dac254af86 | |||
| 8a0fb870f1 | |||
| ff469ce717 | |||
| 5f0966ec8d | |||
| c4f5bb09c6 | |||
| 9a13700390 | |||
| 32c497db63 | |||
| 247bb82154 | |||
| 1e6165d7ff | |||
| 1bf4fec9e6 | |||
| 1d589c5178 | |||
| 9cad1994e7 | |||
| 4751145283 | |||
| 64335a6201 | |||
| 26a69a7c99 | |||
| e1caee99f2 | |||
| 1da3367de8 | |||
| 9e59b01326 | |||
| 7cbd61dd5c | |||
| 1c9dac24e9 | |||
| d6bd38a46a | |||
| 9ab5de8c2e | |||
| 6329427b4d |
@@ -6,6 +6,89 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default text model upgraded to Qwen3.8-Max** — `bl text chat`, pipelines, API key validation, the config UI, and Managed Agent init templates now default to `qwen3.8-max`; Token Plan also moves from the preview model to the stable release.
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config ui` Skills / MCP / Agents / Assets inventory** — browse installed skills, MCP servers, coding agents, and generated assets in the local Web UI with click-to-open detail drawers:
|
||||
- Skills: render `SKILL.md` as Markdown (GFM tables supported), show local vs remote origin badges, and install a skill by uploading a `.zip` archive into any supported agent's skills root.
|
||||
- MCP: view and edit JSON configuration with secret masking and mask-preserving writes; create, update, and delete MCP entries across Claude Code, Qwen Code, OpenCode, Cursor, Windsurf, Gemini, Qoder Work, OpenClaw, and Claude Desktop.
|
||||
- Agents: quick-launch coding agents directly from the UI (gated on the CLI binary being on PATH).
|
||||
- Assets: categorized, time-sorted browser with preview, open-locally, and delete.
|
||||
- **Model catalog suggestion chips** — per-category model names surfaced as click-to-fill chips under each `default_*_model` field in the config UI.
|
||||
- **Profiles tile grid** — profiles displayed as a tile grid with an add-tile and a design-consistent new-profile modal.
|
||||
|
||||
### Changed
|
||||
|
||||
- Config UI layout: collapsible grouped sidebar with icons and persistent state, responsive breakpoint, wider main area, sticky view headers, and right-side drawers for editing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Symlinked skill directories are now correctly identified as an installed source.
|
||||
- Config file detection now supports environment-variable-based paths and legacy configuration schemes.
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config agent --key` / `--region`** — run commands generated by the Model Studio web console as-is: `--key` accepts the console's encoded API key and decodes it locally (use instead of `--api-key`), and `--region` derives the Token Plan endpoint from a region name (use instead of `--base-url`).
|
||||
- **`bl config agent --context-window`** — set the context window written to the OpenClaw configuration (default 256000).
|
||||
- **`bl config agent --wire-api`** — choose the wire protocol written to the Codex configuration; `chat` is kept for legacy Codex 0.80.0 and earlier (a warning is shown).
|
||||
|
||||
### Changed
|
||||
|
||||
- `bl config agent` for Codex now writes `wire_api = "responses"` by default, matching current Codex releases that no longer accept `chat`.
|
||||
- `bl config agent` for Qwen Code now writes the `DASHSCOPE_API_KEY` environment variable instead of `BAILIAN_CLI_API_KEY`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `bl config agent` configurations now match each agent's official format: Claude Code honors `CLAUDE_CONFIG_DIR` and removes a stale `ANTHROPIC_API_KEY`; Qwen Code uses the v3 settings schema and writes credentials so a system-level `OPENAI_API_KEY` no longer takes precedence; OpenCode accepts JSONC config files (comments and trailing commas); OpenClaw registers the primary model in the model allowlist with complete cost metadata; Hermes uses the official flat `model.*` layout; Codex writes the official `env_key` with an `auth.json` fallback.
|
||||
- `bl config agent` now preserves existing user configuration when writing: it merges instead of overwriting, avoids duplicate provider entries, and keeps custom display names.
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- MCP tools and WebSearch now provide activation guidance and direct marketplace links when Bailian reports that the corresponding service is not activated. WebSearch also guides users with legacy SSE connections to reactivate the service using Streamable HTTP.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed text chat and API Key validation compatibility failures caused by sending unsupported `enable_thinking` values. Text chat now sends the parameter only when thinking is explicitly enabled, while validation uses a compatible model without sending it.
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- `bl image edit` now supports `--function` for specifying edit operations with Wanx image-edit models such as `wanx2.1-imageedit`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed image generation and editing failures and incorrect size parameters for some image models, improving compatibility with Qwen-Image, Wan/Wanx, Z-Image, and dated `wanx-v1` variants.
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl managed-agent`** — declaratively manage Managed Agent infrastructure through a unified CLI. The Bailian provider connects to AgentStudio, with Claude, Qoder, and Ark providers also supported:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` — initialize and validate `agents.yaml`, preview and apply resource changes, and destroy managed resources.
|
||||
- `state list` / `state show` / `state rm` / `state import` — inspect and manage local resource state, including adopting an existing remote resource or removing it from local state without destroying it remotely.
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` — manage the full session lifecycle with streaming responses and structured `--output json` output.
|
||||
- `skill-list` — browse custom and official skills; use `--source all` to return both catalogs in one call.
|
||||
|
||||
### Changed
|
||||
|
||||
- Model Base URLs are now normalized to the URL origin; paths, query parameters, and fragments supplied in the Base URL are no longer included when constructing API request paths.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The installation guide no longer recommends the removed `--non-interactive` flag and now documents explicit required arguments, `--output json`, and `NO_COLOR=1` for non-interactive environments.
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -6,6 +6,89 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### 变更
|
||||
|
||||
- **默认文本模型升级至 Qwen3.8-Max** — `bl text chat`、Pipeline、API Key 登录校验、配置 UI 和 Managed Agent 初始化模板现在默认使用 `qwen3.8-max`;Token Plan 也由预览版切换至正式版。
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config ui` 技能 / MCP / 代理 / 资产清单** — 在本地 Web UI 中浏览已安装的技能、MCP 服务器、编码代理和生成的资产,点击打开右侧详情抽屉:
|
||||
- 技能:将 `SKILL.md` 渲染为 Markdown(支持 GFM 表格),展示本地/远程来源徽章,支持上传 `.zip` 压缩包将技能安装到任意受支持代理的技能目录。
|
||||
- MCP:查看和编辑 JSON 配置,支持密钥掩码与掩码保真写回;支持在 Claude Code、Qwen Code、OpenCode、Cursor、Windsurf、Gemini、Qoder Work、OpenClaw 和 Claude Desktop 中创建、更新、删除 MCP 条目。
|
||||
- 代理:从 UI 一键启动编码代理(需对应 CLI 二进制在 PATH 中)。
|
||||
- 资产:按类别分组、按时间排序的浏览器,支持预览、本地打开和删除。
|
||||
- **模型目录建议芯片** — 在配置 UI 的每个 `default_*_model` 字段下方展示按类别分组的模型名称,点击即可填入。
|
||||
- **Profile 磁贴网格** — 配置文件以磁贴网格展示,新增添加磁贴和设计一致的新建 Profile 弹窗。
|
||||
|
||||
### 变更
|
||||
|
||||
- 配置 UI 布局:可折叠分组侧边栏(带图标和持久化状态)、响应式断点、更宽的主区域、吸顶视图标题、右侧抽屉式编辑。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复软链接技能目录未被正确识别为已安装来源的问题。
|
||||
- 配置文件检测现支持基于环境变量的路径和旧版配置方案。
|
||||
|
||||
## [1.12.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent --key` / `--region`** —— 百炼控制台生成的命令可直接运行:`--key` 接收控制台编码后的 API Key 并在本地解码(与 `--api-key` 二选一);`--region` 根据地域名自动派生 Token Plan 接入地址(与 `--base-url` 二选一)。
|
||||
- **`bl config agent --context-window`** —— 设置写入 OpenClaw 配置的上下文窗口大小(默认 256000)。
|
||||
- **`bl config agent --wire-api`** —— 选择写入 Codex 配置的通信协议;`chat` 仅保留给 Codex 0.80.0 及更早版本(会显示警告)。
|
||||
|
||||
### 变更
|
||||
|
||||
- `bl config agent` 配置 Codex 时默认写入 `wire_api = "responses"`,以适配已不再支持 `chat` 的新版 Codex。
|
||||
- `bl config agent` 配置 Qwen Code 时改用 `DASHSCOPE_API_KEY` 环境变量,不再使用 `BAILIAN_CLI_API_KEY`。
|
||||
|
||||
### 修复
|
||||
|
||||
- `bl config agent` 写入的配置现已与各 Agent 官方格式对齐:Claude Code 尊重 `CLAUDE_CONFIG_DIR` 并清理残留的 `ANTHROPIC_API_KEY`;Qwen Code 采用 v3 配置 schema 并正确写入凭证,避免被系统级 `OPENAI_API_KEY` 干扰;OpenCode 支持带注释和尾部逗号的 JSONC 配置文件;OpenClaw 会将主模型注册进模型白名单并补齐计费元数据;Hermes 改用官方扁平 `model.*` 结构;Codex 写入官方 `env_key` 并支持 `auth.json` 兜底。
|
||||
- `bl config agent` 写入配置时现会保留用户已有配置:合并而非覆盖,避免重复添加 provider 条目,并保留用户自定义的显示名。
|
||||
|
||||
## [1.11.2] - 2026-07-28
|
||||
|
||||
### 变更
|
||||
|
||||
- MCP 工具或 WebSearch 因对应服务未开通而不可用时,CLI 现在会提供开通指引和市场直达链接;对于使用旧版 SSE 连接的 WebSearch,还会提示重新开通以切换至 Streamable HTTP。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复文本对话与 API Key 登录校验因传递不受支持的 `enable_thinking` 参数值而产生的兼容性错误。文本对话仅在用户明确开启思考模式时传递该参数,登录校验则改用兼容模型且不再传递该参数。
|
||||
|
||||
## [1.11.1] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- `bl image edit` 新增 `--function` 参数,支持为万相图片编辑模型(如 `wanx2.1-imageedit`)指定编辑功能。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复部分图片模型在图片生成与编辑时的调用失败和尺寸参数错误,并完善 Qwen-Image、Wan/Wanx、Z-Image 系列及 `wanx-v1` 日期版本的兼容性。
|
||||
|
||||
## [1.11.0] - 2026-07-28
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl managed-agent`** —— 通过统一 CLI 声明式管理 Managed Agent 基础设施;百炼 Provider 对接 AgentStudio,并支持 Claude、Qoder 和 Ark:
|
||||
- `init` / `validate` / `plan` / `apply` / `destroy` —— 基于 `agents.yaml` 初始化、校验、预览和执行资源变更,以及销毁已托管资源。
|
||||
- `state list` / `state show` / `state rm` / `state import` —— 查看和管理本地资源状态,包括纳管已有远端资源或仅解除本地跟踪。
|
||||
- `session create` / `session list` / `session get` / `session delete` / `session run` / `session send` / `session events` —— 完整的会话生命周期操作,支持流式响应和结构化的 `--output json` 输出。
|
||||
- `skill-list` —— 浏览自定义与官方 Skill;使用 `--source all` 可一次返回两个来源。
|
||||
|
||||
### 变更
|
||||
|
||||
- 模型 Base URL 现在统一仅保留 URL Origin;传入的路径、查询参数和 Fragment 不再参与后续 API 请求路径拼接。
|
||||
|
||||
### 修复
|
||||
|
||||
- 安装指南不再推荐已移除的 `--non-interactive`,改为说明显式传入必填参数,并使用 `--output json` 或 `NO_COLOR=1` 适配非交互环境。
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -24,7 +24,7 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Text chat** — Qwen3.8-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **文本对话** — Qwen3.8-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
|
||||
@@ -53,6 +53,23 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
|
||||
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
|
||||
|
||||
### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
|
||||
|
||||
`bl managed-agent *` 按调用链分两层:
|
||||
|
||||
- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言
|
||||
- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁,authStage 都经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。
|
||||
|
||||
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:
|
||||
|
||||
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
|
||||
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 SDK zod 层报 "received null")
|
||||
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
|
||||
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
|
||||
5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过
|
||||
|
||||
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
|
||||
|
||||
## 必查清单
|
||||
|
||||
### A. core 层(类型 + 解析)
|
||||
|
||||
@@ -46,7 +46,9 @@
|
||||
- `config list` 标识所有 Profile 与当前激活项。
|
||||
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
|
||||
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
|
||||
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
|
||||
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
|
||||
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
|
||||
- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。
|
||||
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
|
||||
|
||||
## 6. 最小测试矩阵
|
||||
@@ -62,7 +64,8 @@
|
||||
`--config default` 成功后切回 `default`。
|
||||
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
|
||||
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
|
||||
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
|
||||
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
|
||||
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
|
||||
|
||||
## 7. 完成检查
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
|
||||
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
|
||||
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
|
||||
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
|
||||
MCP_WEBSEARCH_PAGE mcpMarketplaceDetailPage("WebSearch")
|
||||
mcpMarketplaceDetailPage BAILIAN_CONSOLE?tab=mcp#/mcp-market/detail/<serverCode>
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
|
||||
@@ -2,4 +2,7 @@ node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
outputs/
|
||||
outputs/
|
||||
# agents
|
||||
agents.state.json
|
||||
.env
|
||||
|
||||
@@ -24,7 +24,7 @@ _Built for AI Agents. Every command works as a structured tool call._
|
||||
|
||||
Equip your AI Agent out-of-the-box with these capabilities, composable across complex tasks:
|
||||
|
||||
- **Text chat** — Qwen3.7-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Text chat** — Qwen3.8-max: major gains in agentic coding, frontend coding, and vibe coding
|
||||
- **Multimodal (Omni)** — Full omni-modal support across text + image + audio + video
|
||||
- **Image generation & editing** — Qwen-Image 2.0: pro text rendering, photorealism, strong semantic adherence, multi-image composition
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
|
||||
@@ -24,7 +24,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
|
||||
让您的 AI Agent 开箱即具备以下能力,并可在复杂任务中自动组合调用:
|
||||
|
||||
- **文本对话** — Qwen3.7-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **文本对话** — Qwen3.8-max:Agentic coding、前端编程、Vibe coding 等能力显著增强
|
||||
- **全模态对话** — 文本 + 图像 + 音频 + 视频全模态支持
|
||||
- **图像生成与编辑** — Qwen-Image 2.0:专业文字渲染、真实质感、强语义遵循、多图合成
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.10.1",
|
||||
"version": "1.13.1",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
|
||||
@@ -89,6 +89,23 @@ import {
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
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.
|
||||
@@ -186,4 +203,21 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
"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,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.10.1",
|
||||
"version": "1.13.1",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
@@ -40,6 +40,7 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openagentpack/sdk": "0.3.1",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
|
||||
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
|
||||
|
||||
/** Read-only auth snapshot the config UI account widget renders. bl stores no
|
||||
* user profile (name/avatar), so this exposes only which credential domains
|
||||
* resolve, the console region/site, and a masked token. */
|
||||
export interface AuthUiStatus {
|
||||
authenticated: boolean;
|
||||
methods: { apiKey: boolean; console: boolean; openapi: boolean };
|
||||
primary: "console" | "apiKey" | "openapi" | null;
|
||||
region?: string;
|
||||
site?: "domestic" | "international";
|
||||
masked?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth capability surface the config UI is allowed to use. All `authStore`
|
||||
* access is kept inside this module (commands/auth/**), which the lint boundary
|
||||
* permits; commands/config/** consumes only this opaque bridge and never
|
||||
* touches `authStore` directly.
|
||||
*/
|
||||
export interface AuthUiBridge {
|
||||
status(): AuthUiStatus;
|
||||
/** Start browser-based console login (fire-and-forget; UI polls status). */
|
||||
startConsoleLogin(): void;
|
||||
/** Clear all stored credentials. Returns whether anything changed. */
|
||||
logout(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Build the bridge from a command context (identity/settings/authStore). */
|
||||
export function makeAuthUiBridge(ctx: {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
authStore: AuthStore;
|
||||
}): AuthUiBridge {
|
||||
const { identity, settings, authStore } = ctx;
|
||||
return {
|
||||
status() {
|
||||
const a = authStore.describe();
|
||||
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
|
||||
let masked: string | undefined;
|
||||
if (a.console) masked = maskToken(a.console.token);
|
||||
else if (a.apiKey) masked = maskToken(a.apiKey.token);
|
||||
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
|
||||
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
|
||||
return {
|
||||
authenticated: methods.apiKey || methods.console || methods.openapi,
|
||||
methods,
|
||||
primary,
|
||||
region: a.console?.region,
|
||||
site: a.console?.site,
|
||||
masked,
|
||||
};
|
||||
},
|
||||
startConsoleLogin() {
|
||||
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
|
||||
// Mirror the CLI (`bl auth login --console`): request an api_key from the
|
||||
// console only when one isn't already stored, so a first console login in
|
||||
// the config UI also provisions the model api_key (not just access_token).
|
||||
const hasApiKey = !!authStore.stored().apiKey;
|
||||
// runConsoleLogin opens the browser and runs its own callback server
|
||||
// (up to 15 min). We don't await it — the config UI polls the status
|
||||
// endpoint to detect completion. Errors are logged, not surfaced.
|
||||
void runConsoleLogin(
|
||||
origin,
|
||||
{ identity, settings, authStore },
|
||||
{
|
||||
needApiKey: !hasApiKey,
|
||||
},
|
||||
).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`console login failed: ${msg}\n`);
|
||||
});
|
||||
},
|
||||
logout() {
|
||||
return authStore.logout("all");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function validateAndPersistApiKey(
|
||||
const persistBaseUrl = profile.persistBaseUrl
|
||||
? normalizeModelBaseUrl(profile.persistBaseUrl)
|
||||
: undefined;
|
||||
const validationModel = profile.defaultTextModel || "qwen3.7-max";
|
||||
const validationModel = "qwen3.8-max";
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
@@ -68,7 +68,6 @@ export async function validateAndPersistApiKey(
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
enable_thinking: validationModel === "qwen3.8-max-preview",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ export default defineCommand({
|
||||
usageArgs:
|
||||
"--api-key <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
|
||||
flags: {
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "Model API key to store",
|
||||
},
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Best-effort local launcher for coding-agent CLIs surfaced in the config UI.
|
||||
*
|
||||
* The command for each agent is taken from a fixed allowlist keyed by the
|
||||
* agent id, so no user-controlled string is ever executed. Every child process
|
||||
* is spawned via `execFile` (array args, no shell) to avoid injection.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */
|
||||
export const AGENT_COMMANDS: Record<string, string> = {
|
||||
"claude-code": "claude",
|
||||
"qwen-code": "qwen",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
hermes: "hermes",
|
||||
codex: "codex",
|
||||
};
|
||||
|
||||
/** The launch binary for a known agent id, or undefined when unknown. */
|
||||
export function agentCommand(id: string): string | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent argv that passes an initial task prompt while keeping the agent
|
||||
* interactive in the terminal. Only verified contracts are listed; an agent
|
||||
* absent here cannot be dispatched a prompt (its bare launch still works).
|
||||
* - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive)
|
||||
* - claude-code: `claude "<prompt>"` (positional initial prompt)
|
||||
* - codex: `codex "<prompt>"` (positional initial prompt)
|
||||
*/
|
||||
const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = {
|
||||
"qwen-code": (p) => ["-i", p],
|
||||
"claude-code": (p) => [p],
|
||||
codex: (p) => [p],
|
||||
};
|
||||
|
||||
/** Whether a known agent supports being dispatched an initial task prompt. */
|
||||
export function agentSupportsPrompt(id: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id);
|
||||
}
|
||||
|
||||
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
|
||||
function onPath(bin: string): Promise<boolean> {
|
||||
const cmd = process.platform === "win32" ? "where" : "which";
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a known agent can actually be quick-launched right now: its id maps to
|
||||
* a launch binary and that binary is reachable on PATH. Unknown ids resolve to
|
||||
* false. Used to gate the UI's Quick launch button so "Connected" agents whose
|
||||
* CLI is not installed do not offer a launch that would immediately fail.
|
||||
*/
|
||||
export function agentLaunchable(id: string): Promise<boolean> {
|
||||
const command = agentCommand(id);
|
||||
if (!command) return Promise.resolve(false);
|
||||
return onPath(command);
|
||||
}
|
||||
|
||||
/** Single-quote a path for a POSIX shell command line. */
|
||||
function shQuote(p: string): string {
|
||||
return `'${p.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */
|
||||
function spawnTerminal(command: string, cwd: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (platform === "darwin") {
|
||||
const inner = `cd ${shQuote(cwd)} && ${command}`;
|
||||
const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const args = [
|
||||
"-e",
|
||||
`tell application "Terminal" to do script "${escaped}"`,
|
||||
"-e",
|
||||
'tell application "Terminal" to activate',
|
||||
];
|
||||
execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
||||
return;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`];
|
||||
execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
|
||||
return;
|
||||
}
|
||||
// Linux / other: best-effort via the distro's default terminal emulator.
|
||||
const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`;
|
||||
execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) =>
|
||||
err ? reject(new Error("No supported terminal emulator was found")) : resolve(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface LaunchResult {
|
||||
launched: boolean;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a known coding agent's local CLI in a new terminal window. When
|
||||
* `prompt` is provided, it is passed as a single quoted argument using the
|
||||
* agent's verified prompt contract so the agent starts with that task.
|
||||
* Rejects when the id is unknown, the binary is missing from PATH, the agent
|
||||
* does not support prompt dispatch, or the platform terminal could not open.
|
||||
*/
|
||||
export async function launchAgent(
|
||||
id: string,
|
||||
cwd: string = process.cwd(),
|
||||
prompt?: string,
|
||||
): Promise<LaunchResult> {
|
||||
const command = agentCommand(id);
|
||||
if (!command) throw new Error(`Unknown agent: ${id}`);
|
||||
if (!(await onPath(command))) {
|
||||
throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`);
|
||||
}
|
||||
let fullCommand = command;
|
||||
const task = (prompt ?? "").trim();
|
||||
if (task) {
|
||||
const build = AGENT_PROMPT_ARGV[id];
|
||||
if (!build) throw new Error(`${id} does not support dispatching a task prompt.`);
|
||||
// shQuote keeps the whole prompt as one shell argument (no injection); the
|
||||
// platform terminal layer escapes the resulting command line separately.
|
||||
fullCommand = [command, ...build(task).map(shQuote)].join(" ");
|
||||
}
|
||||
await spawnTerminal(fullCommand, cwd);
|
||||
return { launched: true, command: fullCommand };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Decoder for the obfuscated API key ("o1_…") produced by the Model Studio web
|
||||
* console. Ported verbatim from the frontend `encodeTokenPlanKey` counterpart:
|
||||
* token = "o1_" + salt(6) + feistel-obfuscated payload + crc32 checksum(6),
|
||||
* all over a 65-character alphabet. Pure logic, no dependencies; the CLI only
|
||||
* ever needs the decode direction.
|
||||
*/
|
||||
|
||||
const TOKEN_PREFIX = "o1_";
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.";
|
||||
const ALPHABET_SIZE = ALPHABET.length;
|
||||
const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index]));
|
||||
const KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
const SALT_LENGTH = 6;
|
||||
const CHECKSUM_LENGTH = 6;
|
||||
const FEISTEL_ROUNDS = 8;
|
||||
|
||||
function invalidCredential(): BailianError {
|
||||
return new BailianError(
|
||||
"Invalid obfuscated API key.",
|
||||
ExitCode.USAGE,
|
||||
'--key expects the obfuscated key copied from the web console (starts with "o1_").',
|
||||
);
|
||||
}
|
||||
|
||||
function toDigits(value: string): number[] {
|
||||
const digits: number[] = [];
|
||||
for (const character of value) {
|
||||
const digit = ALPHABET_INDEX.get(character);
|
||||
if (digit === undefined) throw invalidCredential();
|
||||
digits.push(digit);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
function fromDigits(digits: number[]): string {
|
||||
return digits.map((digit) => ALPHABET[digit]).join("");
|
||||
}
|
||||
|
||||
function mixState(state: number, value: number): number {
|
||||
return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
function nextState(state: number): number {
|
||||
let next = state >>> 0;
|
||||
next ^= next << 13;
|
||||
next ^= next >>> 17;
|
||||
next ^= next << 5;
|
||||
return next >>> 0;
|
||||
}
|
||||
|
||||
function createRoundMask(right: number[], salt: string, round: number, length: number): number[] {
|
||||
let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0;
|
||||
|
||||
state = mixState(state, right.length);
|
||||
state = mixState(state, length);
|
||||
for (const character of salt) {
|
||||
state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1);
|
||||
}
|
||||
for (const digit of right) {
|
||||
state = mixState(state, digit + 1);
|
||||
}
|
||||
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 0x85ebca6b) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 0xc2b2ae35) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = state >>> 0 || 0x6d2b79f5;
|
||||
|
||||
const mask: number[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0;
|
||||
state = nextState(state);
|
||||
mask.push(state % ALPHABET_SIZE);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function deobfuscatePayload(payload: string, salt: string): string {
|
||||
const digits = toDigits(payload);
|
||||
const midpoint = Math.floor(digits.length / 2);
|
||||
let left = digits.slice(0, midpoint);
|
||||
let right = digits.slice(midpoint);
|
||||
|
||||
for (let round = FEISTEL_ROUNDS - 1; round >= 0; round -= 1) {
|
||||
const previousRight = left;
|
||||
const mask = createRoundMask(previousRight, salt, round, right.length);
|
||||
const previousLeft = right.map(
|
||||
(digit, index) => (digit - mask[index] + ALPHABET_SIZE) % ALPHABET_SIZE,
|
||||
);
|
||||
left = previousLeft;
|
||||
right = previousRight;
|
||||
}
|
||||
|
||||
return fromDigits([...left, ...right]);
|
||||
}
|
||||
|
||||
function crc32(value: string): number {
|
||||
let checksum = 0xffffffff;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
checksum ^= value.charCodeAt(index);
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
const mask = -(checksum & 1);
|
||||
checksum = (checksum >>> 1) ^ (0xedb88320 & mask);
|
||||
}
|
||||
}
|
||||
return (checksum ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function encodeBase65Number(value: number, length: number): string {
|
||||
let remaining = value >>> 0;
|
||||
const encoded = Array<string>(length).fill(ALPHABET[0]);
|
||||
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
encoded[index] = ALPHABET[remaining % ALPHABET_SIZE];
|
||||
remaining = Math.floor(remaining / ALPHABET_SIZE);
|
||||
}
|
||||
if (remaining !== 0) throw invalidCredential();
|
||||
return encoded.join("");
|
||||
}
|
||||
|
||||
function validateSalt(salt: string): void {
|
||||
if (salt.length !== SALT_LENGTH || !KEY_PATTERN.test(salt)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an "o1_…" obfuscated token back into the plain API key. */
|
||||
export function decodeTokenPlanKey(token: string): string {
|
||||
const minimumLength = TOKEN_PREFIX.length + SALT_LENGTH + CHECKSUM_LENGTH + 1;
|
||||
if (token.length < minimumLength || !token.startsWith(TOKEN_PREFIX)) {
|
||||
throw invalidCredential();
|
||||
}
|
||||
|
||||
const body = token.slice(TOKEN_PREFIX.length);
|
||||
if (!KEY_PATTERN.test(body)) throw invalidCredential();
|
||||
|
||||
const salt = body.slice(0, SALT_LENGTH);
|
||||
const payload = body.slice(SALT_LENGTH, -CHECKSUM_LENGTH);
|
||||
const checksum = body.slice(-CHECKSUM_LENGTH);
|
||||
validateSalt(salt);
|
||||
if (!payload) throw invalidCredential();
|
||||
|
||||
const apiKey = deobfuscatePayload(payload, salt);
|
||||
if (!KEY_PATTERN.test(apiKey)) throw invalidCredential();
|
||||
|
||||
const expectedChecksum = encodeBase65Number(crc32(apiKey), CHECKSUM_LENGTH);
|
||||
if (checksum !== expectedChecksum) throw invalidCredential();
|
||||
return apiKey;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
import { decodeTokenPlanKey } from "./decode-key.ts";
|
||||
import { resolveRegionBaseUrl } from "./writers/utils.ts";
|
||||
|
||||
const FLAGS = {
|
||||
agent: {
|
||||
@@ -11,30 +13,76 @@ const FLAGS = {
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "API base URL",
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
valueHint: "<region>",
|
||||
description:
|
||||
"Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only",
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "API key",
|
||||
},
|
||||
key: {
|
||||
type: "string",
|
||||
valueHint: "<encoded>",
|
||||
description:
|
||||
'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key',
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
contextWindow: {
|
||||
type: "number",
|
||||
valueHint: "<tokens>",
|
||||
description: "OpenClaw only: model context window in tokens (default: 256000)",
|
||||
},
|
||||
wireApi: {
|
||||
type: "string",
|
||||
valueHint: "<api>",
|
||||
description:
|
||||
'Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0',
|
||||
choices: ["chat", "responses"],
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Configure a coding agent to use DashScope API",
|
||||
auth: "none",
|
||||
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
|
||||
usageArgs:
|
||||
"--agent <name> (--base-url <url> | --region <region>) (--api-key <key> | --key <encoded>) --model <model>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: [
|
||||
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
|
||||
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
],
|
||||
validate(flags) {
|
||||
if (!flags.baseUrl && !flags.region) return "one of --base-url or --region is required";
|
||||
if (flags.baseUrl && flags.region) return "--base-url and --region are mutually exclusive";
|
||||
if (!flags.apiKey && !flags.key) return "one of --api-key or --key is required";
|
||||
if (flags.apiKey && flags.key) return "--api-key and --key are mutually exclusive";
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { baseUrl, apiKey, model } = flags;
|
||||
const { model, contextWindow, wireApi } = flags;
|
||||
// --region is a Token Plan convenience: convert it into a base URL and use
|
||||
// it exactly as --base-url would be.
|
||||
const baseUrl = flags.region ? resolveRegionBaseUrl(flags.region) : flags.baseUrl!;
|
||||
// --key carries the web console's obfuscated form; decode it up front so
|
||||
// even --dry-run validates the token.
|
||||
const apiKey = flags.key ? decodeTokenPlanKey(flags.key) : flags.apiKey!;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -59,13 +107,22 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const params: WriteParams = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
contextWindow,
|
||||
wireApi,
|
||||
};
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
emitBare(`${agentDef.label} configured successfully.`);
|
||||
for (const path of summary.paths) emitBare(` Written: ${path}`);
|
||||
emitBare(` ${summary.nextStep}`);
|
||||
for (const warning of summary.warnings ?? []) {
|
||||
process.stderr.write(`Warning: ${warning}\n`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,55 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
resolveClaudeCodeBaseUrl,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** Fill a tier/default model env only when the user has not set it yet. */
|
||||
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
|
||||
const current = env[key];
|
||||
if (current === undefined || current.trim() === "") {
|
||||
env[key] = model;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
||||
const settingsPath = join(configDir, "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
const warnings: string[] = [];
|
||||
|
||||
const resolved = resolveClaudeCodeBaseUrl(baseUrl);
|
||||
if (resolved.rewrittenFrom) {
|
||||
warnings.push(
|
||||
`Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` +
|
||||
`(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`,
|
||||
);
|
||||
}
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
// the endpoint; the model tier vars force every tier onto the chosen model.
|
||||
// the Anthropic-compatible endpoint; primary model always updates, while
|
||||
// tier/subagent defaults are filled only when absent so existing setups
|
||||
// (e.g. Token Plan Haiku/Subagent splits) are not wiped.
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.ANTHROPIC_BASE_URL = resolved.url;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
// AUTH_TOKEN and API_KEY are mutually exclusive credential fields — drop a
|
||||
// stale ANTHROPIC_API_KEY so it cannot shadow the token we just wrote.
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
|
||||
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model);
|
||||
setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model);
|
||||
settings.env = env;
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
@@ -32,6 +62,7 @@ export default {
|
||||
return {
|
||||
paths: [settingsPath, onboardingPath],
|
||||
nextStep: "Run `claude` to start using Claude Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -8,8 +8,9 @@ const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, wireApi: wireApiParam }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
const warnings: string[] = [];
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
@@ -25,8 +26,19 @@ export default {
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
// wire_api — current Codex releases only load `wire_api = "responses"`
|
||||
// ("chat" is rejected at config load, see openai/codex discussion #7782).
|
||||
// "chat" remains an explicit opt-in for users pinned to legacy Codex
|
||||
// <= 0.80.0 (the Model Studio path for models without Responses support).
|
||||
const wireApi = wireApiParam === "chat" ? "chat" : "responses";
|
||||
if (wireApi === "chat") {
|
||||
warnings.push(
|
||||
'Current Codex releases refuse to load `wire_api = "chat"`; ' +
|
||||
"only use --wire-api chat with legacy Codex <= 0.80.0 " +
|
||||
"(e.g. `npm install -g @openai/codex@0.80.0`).",
|
||||
);
|
||||
}
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
@@ -34,14 +46,17 @@ export default {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
wire_api: "responses",
|
||||
// env_key is the official-doc credential mechanism: Codex resolves the
|
||||
// key from the OPENAI_API_KEY env var, falling back to auth.json below.
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: wireApi,
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
@@ -51,6 +66,7 @@ export default {
|
||||
return {
|
||||
paths: [configPath, authPath],
|
||||
nextStep: "Run `codex` to start using Codex with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
@@ -22,26 +20,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
// Official Model Studio doc shape: a single flat `model` block holding the
|
||||
// active endpoint + credentials. `api_mode: anthropic_messages` is required
|
||||
// for /apps/anthropic endpoints; for the OpenAI-compatible endpoint the
|
||||
// doc says to omit api_mode entirely (chat completions is the default).
|
||||
const block: Record<string, unknown> = {
|
||||
default: model,
|
||||
provider: "custom",
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
if (isAnthropicEndpoint(baseUrl)) block.api_mode = "anthropic_messages";
|
||||
config.model = block;
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
|
||||
@@ -2,20 +2,36 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
// Safe default when --context-window is not given: most Model Studio models
|
||||
// offer ≥256K context; users can raise it per model via the flag.
|
||||
const DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
|
||||
const PROVIDER_ID = "bailian-cli";
|
||||
|
||||
function readPrimary(defaults: Record<string, unknown>): string | undefined {
|
||||
const model = defaults.model;
|
||||
if (!model || typeof model !== "object") return undefined;
|
||||
const primary = (model as Record<string, unknown>).primary;
|
||||
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
|
||||
}
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, contextWindow }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const warnings: string[] = [];
|
||||
const modelRef = `${PROVIDER_ID}/${model}`;
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
// models.providers["bailian-cli"]
|
||||
// models.providers["bailian-cli"] — upsert without removing other providers
|
||||
// (e.g. an existing working bailian-token-plan setup).
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
providers[PROVIDER_ID] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
@@ -23,18 +39,35 @@ export default {
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
// agents.defaults — register the model in the allow-list. Only set primary
|
||||
// when unset, or when primary already points at bailian-cli (reconfigure).
|
||||
// Never steal primary away from another provider such as bailian-token-plan.
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
|
||||
allowedModels[modelRef] = allowedModels[modelRef] ?? {};
|
||||
defaults.models = allowedModels;
|
||||
|
||||
const existingPrimary = readPrimary(defaults);
|
||||
if (!existingPrimary) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) {
|
||||
defaults.model = { primary: modelRef };
|
||||
} else {
|
||||
warnings.push(
|
||||
`Left existing primary model unchanged ("${existingPrimary}"). ` +
|
||||
`Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`,
|
||||
);
|
||||
}
|
||||
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
@@ -42,7 +75,9 @@ export default {
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
|
||||
nextStep:
|
||||
"Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import { backup, readJsonc, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
// opencode.json is JSONC — tolerate comments and trailing commas on read.
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
const config = readJsonc(configPath);
|
||||
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
|
||||
@@ -2,53 +2,110 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
const ENV_KEY = "DASHSCOPE_API_KEY";
|
||||
|
||||
function displayName(model: string): string {
|
||||
return `[Bailian] ${model}`;
|
||||
}
|
||||
|
||||
/** Entries we previously wrote, or still own via envKey / display brand. */
|
||||
function isBailianCliEntry(entry: Record<string, unknown>): boolean {
|
||||
if (entry.envKey === ENV_KEY) return true;
|
||||
const name = typeof entry.name === "string" ? entry.name : "";
|
||||
return name === "bailian-cli" || name.startsWith("[Bailian]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the model entry `name` and the env var name.
|
||||
* therefore lives in the env var name (`BAILIAN_CLI_API_KEY`) and the display
|
||||
* label (`[Bailian] …`); Qwen Code keys models by id (+ baseUrl), never by name.
|
||||
*
|
||||
* Qwen Code does not support duplicate model `id`s (only the first loads), so
|
||||
* we must never overwrite a pre-existing Token Plan / third-party entry that
|
||||
* shares the same id.
|
||||
*
|
||||
* Credentials are written to BOTH `env` (via the entry's `envKey`) and
|
||||
* `security.auth` — the resolver reads `security.auth.apiKey/baseUrl` as a
|
||||
* lower-priority layer, which stops a stray system `OPENAI_API_KEY` from being
|
||||
* picked up when the provider→envKey path does not resolve first. The active
|
||||
* `model` also carries its `baseUrl`, as Qwen Code requires to disambiguate
|
||||
* same-id providers.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".qwen", "settings.json");
|
||||
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
|
||||
const warnings: string[] = [];
|
||||
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// $version — Qwen Code v3 settings schema (official Model Studio doc shape).
|
||||
settings.$version = 3;
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
// Qwen Code treats settings.json `env` as lowest priority; a process/shell
|
||||
// value for the same key wins and can make the first launch fail.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
|
||||
const processEnvValue = process.env[ENV_KEY];
|
||||
if (processEnvValue !== undefined && processEnvValue !== apiKey) {
|
||||
warnings.push(
|
||||
`Shell/environment ${ENV_KEY} is set and overrides settings.json. ` +
|
||||
`Unset it (e.g. \`unset ${ENV_KEY}\`) so the key written here takes effect.`,
|
||||
);
|
||||
}
|
||||
|
||||
// modelProviders[<protocol>] — upsert only bailian-cli-owned entries.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = "bailian-cli";
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model);
|
||||
const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model);
|
||||
|
||||
if (owned) {
|
||||
owned.baseUrl = baseUrl;
|
||||
owned.envKey = ENV_KEY;
|
||||
const currentName = typeof owned.name === "string" ? owned.name.trim() : "";
|
||||
if (!currentName || currentName === "bailian-cli") owned.name = displayName(model);
|
||||
} else if (conflicting) {
|
||||
const existingName =
|
||||
typeof conflicting.name === "string" && conflicting.name.length > 0
|
||||
? conflicting.name
|
||||
: String(conflicting.id);
|
||||
warnings.push(
|
||||
`Model id "${model}" already exists as "${existingName}"; left unchanged ` +
|
||||
`(Qwen Code loads only the first entry per id). Remove or rename that ` +
|
||||
`entry if you want bailian-cli to own this model.`,
|
||||
);
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
entries.push({
|
||||
id: model,
|
||||
name: displayName(model),
|
||||
baseUrl,
|
||||
envKey: ENV_KEY,
|
||||
});
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
// security.auth — select the protocol AND keep credentials as a fallback
|
||||
// layer (see the file-level note): without this, a stray system
|
||||
// OPENAI_API_KEY can win when the provider→envKey lookup does not resolve.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
// model — active model. baseUrl MUST be written alongside name; Qwen Code
|
||||
// uses it to disambiguate same-id providers, and omitting it can misroute
|
||||
// to a different entry (and thus a different credential).
|
||||
settings.model = { name: model, baseUrl };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
@@ -56,6 +113,7 @@ export default {
|
||||
return {
|
||||
paths: [settingsPath],
|
||||
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** OpenClaw model entry context window (tokens). */
|
||||
contextWindow?: number;
|
||||
/** Codex provider wire protocol: "responses" or "chat". */
|
||||
wireApi?: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
export interface WriteSummary {
|
||||
paths: string[];
|
||||
nextStep: string;
|
||||
/** Non-fatal issues the command should surface to the user. */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
/** An agent configuration writer: a human label plus a `write` that applies it. */
|
||||
@@ -20,6 +27,83 @@ export interface AgentDef {
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip JSONC syntax (line / block comments and trailing commas) so the result
|
||||
* parses with `JSON.parse`. String contents are preserved verbatim.
|
||||
*/
|
||||
export function stripJsonc(text: string): string {
|
||||
// Pass 1 — drop comments (string contents preserved verbatim).
|
||||
let uncommented = "";
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (inString) {
|
||||
uncommented += char;
|
||||
if (char === "\\") {
|
||||
uncommented += next ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
while (index < text.length && text[index] !== "\n") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) index += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// Pass 2 — drop trailing commas (a comma whose next non-whitespace char
|
||||
// closes an object/array). Runs after comment removal so a trailing comment
|
||||
// cannot hide the closing bracket.
|
||||
let output = "";
|
||||
index = 0;
|
||||
inString = false;
|
||||
while (index < uncommented.length) {
|
||||
const char = uncommented[index];
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (char === "\\") {
|
||||
output += uncommented[index + 1] ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = true;
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (lookahead < uncommented.length && /\s/.test(uncommented[lookahead])) lookahead += 1;
|
||||
if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
@@ -30,6 +114,16 @@ export function readJson(path: string): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */
|
||||
export function readJsonc(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -57,3 +151,65 @@ export function backup(path: string): void {
|
||||
export function isAnthropicEndpoint(baseUrl: string): boolean {
|
||||
return baseUrl.includes("/apps/anthropic");
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code speaks Anthropic Messages only. Users often paste the OpenAI
|
||||
* compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise
|
||||
* fail with a clear USAGE error before writing a broken config.
|
||||
*/
|
||||
export function resolveClaudeCodeBaseUrl(baseUrl: string): {
|
||||
url: string;
|
||||
rewrittenFrom?: string;
|
||||
} {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
|
||||
if (isAnthropicEndpoint(trimmed)) {
|
||||
return { url: trimmed };
|
||||
}
|
||||
|
||||
if (trimmed.includes("/compatible-mode")) {
|
||||
const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic");
|
||||
return { url: rewritten, rewrittenFrom: baseUrl.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const host = parsed.hostname;
|
||||
const isDashScopeHost =
|
||||
host.includes("dashscope") ||
|
||||
host.includes("maas.aliyuncs.com") ||
|
||||
host.includes("token-plan");
|
||||
if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) {
|
||||
return {
|
||||
url: `${parsed.origin}/apps/anthropic`,
|
||||
rewrittenFrom: baseUrl.trim(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the USAGE error below.
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Model Studio region id into a Token Plan base URL, used in place of
|
||||
* --base-url. Produces the OpenAI-compatible endpoint; the claude-code writer
|
||||
* rewrites it to /apps/anthropic on its own, and the other writers consume the
|
||||
* compatible-mode URL directly.
|
||||
*/
|
||||
export function resolveRegionBaseUrl(region: string): string {
|
||||
const normalized = region.trim();
|
||||
if (!/^[a-z0-9-]+$/.test(normalized)) {
|
||||
throw new BailianError(
|
||||
`Invalid --region "${region}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use a Model Studio region id, e.g. cn-beijing or ap-southeast-1.",
|
||||
);
|
||||
}
|
||||
return `https://token-plan.${normalized}.maas.aliyuncs.com/compatible-mode/v1`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Read/manage the local assets that `bl` writes into the output directory
|
||||
// (default ~/bailian-output, overridable via the `output_dir` config key).
|
||||
// Generated media may live directly under the base or in any subfolder (bl's
|
||||
// own images/, videos/, speech/, omni/, or user-created folders). This module
|
||||
// recursively discovers every file under the base, classifies each by type,
|
||||
// derives its category from the top-level folder, and provides safe path
|
||||
// resolution for serving/deleting individual assets.
|
||||
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, extname, relative, resolve, sep } from "node:path";
|
||||
|
||||
export type AssetKind = "image" | "video" | "audio" | "other";
|
||||
|
||||
/** One generated file discovered under the output directory. */
|
||||
export interface AssetInfo {
|
||||
name: string;
|
||||
/** Category folder the file lives in: images | videos | speech | omni | other. */
|
||||
category: string;
|
||||
kind: AssetKind;
|
||||
/** Path relative to the output base (used as the API handle). */
|
||||
relPath: string;
|
||||
size: number;
|
||||
/** Modification time in epoch milliseconds ~= generation time. */
|
||||
mtime: number;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
/** Max directory depth to descend from the output base when scanning. */
|
||||
const MAX_SCAN_DEPTH = 8;
|
||||
|
||||
const KIND_BY_EXT: Record<string, AssetKind> = {
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".webp": "image",
|
||||
".gif": "image",
|
||||
".bmp": "image",
|
||||
".svg": "image",
|
||||
".mp4": "video",
|
||||
".mov": "video",
|
||||
".webm": "video",
|
||||
".mkv": "video",
|
||||
".avi": "video",
|
||||
".mp3": "audio",
|
||||
".wav": "audio",
|
||||
".m4a": "audio",
|
||||
".aac": "audio",
|
||||
".flac": "audio",
|
||||
".ogg": "audio",
|
||||
};
|
||||
|
||||
const CONTENT_TYPE: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".ogg": "audio/ogg",
|
||||
};
|
||||
|
||||
/** The default output base when `output_dir` is not configured. */
|
||||
export function defaultOutputBase(home: string = homedir()): string {
|
||||
return join(home, "bailian-output");
|
||||
}
|
||||
|
||||
function kindOf(ext: string): AssetKind {
|
||||
return KIND_BY_EXT[ext.toLowerCase()] ?? "other";
|
||||
}
|
||||
|
||||
/** MIME type for serving an asset; falls back to a safe binary type. */
|
||||
export function contentType(ext: string): string {
|
||||
return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Recursively collect regular files under `dir`, descending at most `depth` levels. */
|
||||
function walk(dir: string, depth: number, out: string[]): void {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (depth > 0) walk(full, depth - 1, out);
|
||||
} else if (e.isFile() || e.isSymbolicLink()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List generated assets under `base`, newest first. Recursively scans every
|
||||
* subfolder under the base (plus loose files at the root), so assets in bl's
|
||||
* own category dirs and any user-created folders are all discovered. Each
|
||||
* file's `category` is its top-level folder name, or "other" for root files.
|
||||
* Returns the resolved base so callers can surface it in the UI.
|
||||
*/
|
||||
export function listAssets(base: string = defaultOutputBase()): {
|
||||
base: string;
|
||||
assets: AssetInfo[];
|
||||
} {
|
||||
const assets: AssetInfo[] = [];
|
||||
if (!existsSync(base)) return { base, assets };
|
||||
|
||||
const files: string[] = [];
|
||||
walk(base, MAX_SCAN_DEPTH, files);
|
||||
|
||||
for (const full of files) {
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile()) continue;
|
||||
const rel = relative(base, full);
|
||||
const segments = rel.split(sep);
|
||||
const category = segments.length > 1 ? segments[0]! : "other";
|
||||
const ext = extname(full);
|
||||
assets.push({
|
||||
name: full.split(sep).pop() ?? full,
|
||||
category,
|
||||
kind: kindOf(ext),
|
||||
relPath: rel,
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
ext: ext.replace(/^\./, "").toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
assets.sort((a, b) => b.mtime - a.mtime);
|
||||
return { base, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client-supplied relative path to an absolute path strictly inside
|
||||
* `base`. Returns null for empty input or any path that would escape the base
|
||||
* (path traversal guard).
|
||||
*/
|
||||
export function resolveAssetPath(base: string, relPath: string): string | null {
|
||||
if (typeof relPath !== "string" || relPath.length === 0) return null;
|
||||
const root = resolve(base);
|
||||
const abs = resolve(root, relPath);
|
||||
if (abs !== root && !abs.startsWith(root + sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Minimal, dependency-free QR Code encoder used by the config UI to show a
|
||||
* scannable code for the current session URL.
|
||||
*
|
||||
* Scope is deliberately narrow: byte mode, error-correction level L, versions
|
||||
* 1–5 (21x21 … 37x37). Restricting to level L keeps every supported version a
|
||||
* single Reed–Solomon block, so no codeword interleaving is required. Version 5
|
||||
* (level L) holds up to 108 data bytes, comfortably more than a
|
||||
* `http://127.0.0.1:<port>/?token=<hex>` URL.
|
||||
*
|
||||
* The output is an SVG string with a 4-module quiet zone and a `viewBox` only
|
||||
* (no fixed width/height), so the caller sizes it via CSS.
|
||||
*/
|
||||
|
||||
// --- GF(256) arithmetic (primitive polynomial 0x11D) ---
|
||||
|
||||
const EXP = new Uint8Array(512);
|
||||
const LOG = new Uint8Array(256);
|
||||
(() => {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) {
|
||||
EXP[i] = x;
|
||||
LOG[x] = i;
|
||||
x <<= 1;
|
||||
if (x & 0x100) x ^= 0x11d;
|
||||
}
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
|
||||
function gmul(a: number, b: number): number {
|
||||
if (a === 0 || b === 0) return 0;
|
||||
return EXP[LOG[a] + LOG[b]];
|
||||
}
|
||||
|
||||
/** Reed–Solomon generator polynomial for `degree` EC codewords (alpha exponents). */
|
||||
export function rsGeneratorExp(degree: number): number[] {
|
||||
let poly = [1];
|
||||
for (let i = 0; i < degree; i++) {
|
||||
const next: number[] = Array.from({ length: poly.length + 1 }, () => 0);
|
||||
for (let j = 0; j < poly.length; j++) {
|
||||
next[j] ^= poly[j];
|
||||
next[j + 1] ^= gmul(poly[j], EXP[i]);
|
||||
}
|
||||
poly = next;
|
||||
}
|
||||
return poly.map((v) => LOG[v]);
|
||||
}
|
||||
|
||||
/** Compute `ecLen` Reed–Solomon error-correction codewords for `data`. */
|
||||
export function rsEncode(data: number[], ecLen: number): number[] {
|
||||
const gen = rsGeneratorExp(ecLen);
|
||||
const res = new Uint8Array(data.length + ecLen);
|
||||
res.set(data, 0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const coef = res[i];
|
||||
if (coef !== 0) {
|
||||
const lead = LOG[coef];
|
||||
for (let j = 0; j < gen.length; j++) res[i + j] ^= EXP[(gen[j] + lead) % 255];
|
||||
}
|
||||
}
|
||||
return Array.from(res.slice(data.length));
|
||||
}
|
||||
|
||||
// --- Capacity table: [data codewords, EC codewords] per version at level L ---
|
||||
|
||||
const CAP_L: Array<[number, number]> = [
|
||||
[19, 7], // V1 (21x21)
|
||||
[34, 10], // V2 (25x25)
|
||||
[55, 15], // V3 (29x29)
|
||||
[80, 20], // V4 (33x33)
|
||||
[108, 26], // V5 (37x37)
|
||||
];
|
||||
|
||||
const EC_BITS_L = 0b01; // format-info error-correction level bits for L
|
||||
|
||||
function pickVersion(byteLen: number): number {
|
||||
const bits = 4 + 8 + byteLen * 8; // mode + 8-bit count (V1–9) + payload
|
||||
for (let v = 0; v < CAP_L.length; v++) {
|
||||
if (CAP_L[v][0] * 8 >= bits) return v + 1;
|
||||
}
|
||||
throw new Error("qr: data too large for supported versions (max 108 bytes)");
|
||||
}
|
||||
|
||||
// --- Bit/codeword assembly ---
|
||||
|
||||
function toCodewords(bytes: Uint8Array, version: number): number[] {
|
||||
const [dataCw] = CAP_L[version - 1];
|
||||
const bits: number[] = [];
|
||||
const put = (val: number, len: number) => {
|
||||
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
|
||||
};
|
||||
put(0b0100, 4); // byte mode
|
||||
put(bytes.length, 8); // character count (versions 1–9)
|
||||
for (const b of bytes) put(b, 8);
|
||||
|
||||
const capBits = dataCw * 8;
|
||||
put(0, Math.min(4, capBits - bits.length)); // terminator
|
||||
while (bits.length % 8 !== 0) bits.push(0); // pad to byte
|
||||
|
||||
const data: number[] = [];
|
||||
for (let i = 0; i < bits.length; i += 8) {
|
||||
let v = 0;
|
||||
for (let j = 0; j < 8; j++) v = (v << 1) | bits[i + j];
|
||||
data.push(v);
|
||||
}
|
||||
const pads = [0xec, 0x11];
|
||||
for (let p = 0; data.length < dataCw; p++) data.push(pads[p % 2]);
|
||||
|
||||
return data.concat(rsEncode(data, CAP_L[version - 1][1]));
|
||||
}
|
||||
|
||||
// --- Matrix construction ---
|
||||
|
||||
interface Grid {
|
||||
size: number;
|
||||
mod: Uint8Array; // 0/1
|
||||
fn: Uint8Array; // 1 = function/reserved module (skip during data placement)
|
||||
}
|
||||
|
||||
function newGrid(size: number): Grid {
|
||||
return { size, mod: new Uint8Array(size * size), fn: new Uint8Array(size * size) };
|
||||
}
|
||||
|
||||
function setFn(g: Grid, r: number, c: number, dark: number): void {
|
||||
g.mod[r * g.size + c] = dark;
|
||||
g.fn[r * g.size + c] = 1;
|
||||
}
|
||||
|
||||
function drawFinder(g: Grid, r: number, c: number): void {
|
||||
for (let dr = -1; dr <= 7; dr++) {
|
||||
for (let dc = -1; dc <= 7; dc++) {
|
||||
const rr = r + dr;
|
||||
const cc = c + dc;
|
||||
if (rr < 0 || rr >= g.size || cc < 0 || cc >= g.size) continue;
|
||||
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
|
||||
const isDark =
|
||||
inRing &&
|
||||
(dr === 0 ||
|
||||
dr === 6 ||
|
||||
dc === 0 ||
|
||||
dc === 6 ||
|
||||
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
|
||||
setFn(g, rr, cc, isDark ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawAlignment(g: Grid, cr: number, cc: number): void {
|
||||
for (let dr = -2; dr <= 2; dr++) {
|
||||
for (let dc = -2; dc <= 2; dc++) {
|
||||
const ring = Math.max(Math.abs(dr), Math.abs(dc));
|
||||
setFn(g, cr + dr, cc + dc, ring === 1 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawFunctionPatterns(g: Grid, version: number): void {
|
||||
const size = g.size;
|
||||
// Timing patterns.
|
||||
for (let i = 0; i < size; i++) {
|
||||
setFn(g, 6, i, i % 2 === 0 ? 1 : 0);
|
||||
setFn(g, i, 6, i % 2 === 0 ? 1 : 0);
|
||||
}
|
||||
// Finder patterns + separators (drawn as the -1 border above).
|
||||
drawFinder(g, 0, 0);
|
||||
drawFinder(g, 0, size - 7);
|
||||
drawFinder(g, size - 7, 0);
|
||||
// Alignment pattern (single, centered) for versions 2–5.
|
||||
if (version >= 2) {
|
||||
const pos = size - 7; // e.g. 18 (V2), 22 (V3), 26 (V4), 30 (V5)
|
||||
drawAlignment(g, pos, pos);
|
||||
}
|
||||
// Reserve format-info areas (values written later).
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (!(i === 6)) g.fn[8 * size + i] = 1;
|
||||
if (!(i === 6)) g.fn[i * size + 8] = 1;
|
||||
}
|
||||
g.fn[8 * size + 6] = 1;
|
||||
g.fn[6 * size + 8] = 1;
|
||||
for (let i = 0; i < 8; i++) g.fn[(size - 1 - i) * size + 8] = 1;
|
||||
for (let i = 0; i < 8; i++) g.fn[8 * size + (size - 1 - i)] = 1;
|
||||
// Dark module.
|
||||
setFn(g, size - 8, 8, 1);
|
||||
}
|
||||
|
||||
function placeData(g: Grid, codewords: number[]): void {
|
||||
const size = g.size;
|
||||
const stream: number[] = [];
|
||||
for (const cw of codewords) for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1);
|
||||
let idx = 0;
|
||||
let upward = true;
|
||||
for (let col = size - 1; col >= 1; col -= 2) {
|
||||
if (col === 6) col = 5; // skip the vertical timing column
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = upward ? size - 1 - i : i;
|
||||
for (const off of [0, 1]) {
|
||||
const cc = col - off;
|
||||
if (g.fn[row * size + cc]) continue;
|
||||
g.mod[row * size + cc] = idx < stream.length ? stream[idx++] : 0;
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
}
|
||||
|
||||
const MASKS: Array<(r: number, c: number) => boolean> = [
|
||||
(r, c) => (r + c) % 2 === 0,
|
||||
(r) => r % 2 === 0,
|
||||
(_r, c) => c % 3 === 0,
|
||||
(r, c) => (r + c) % 3 === 0,
|
||||
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
||||
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
||||
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
];
|
||||
|
||||
function applyMask(g: Grid, mask: number): void {
|
||||
const cond = MASKS[mask];
|
||||
for (let r = 0; r < g.size; r++) {
|
||||
for (let c = 0; c < g.size; c++) {
|
||||
if (!g.fn[r * g.size + c] && cond(r, c)) g.mod[r * g.size + c] ^= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function penalty(g: Grid): number {
|
||||
const size = g.size;
|
||||
const at = (r: number, c: number) => g.mod[r * size + c];
|
||||
let score = 0;
|
||||
// Rule 1: runs of >=5 same-color modules in rows and columns.
|
||||
for (let r = 0; r < size; r++) {
|
||||
let runC = 1;
|
||||
let runR = 1;
|
||||
for (let c = 1; c < size; c++) {
|
||||
if (at(r, c) === at(r, c - 1)) runC++;
|
||||
else {
|
||||
if (runC >= 5) score += runC - 2;
|
||||
runC = 1;
|
||||
}
|
||||
if (at(c, r) === at(c - 1, r)) runR++;
|
||||
else {
|
||||
if (runR >= 5) score += runR - 2;
|
||||
runR = 1;
|
||||
}
|
||||
}
|
||||
if (runC >= 5) score += runC - 2;
|
||||
if (runR >= 5) score += runR - 2;
|
||||
}
|
||||
// Rule 2: 2x2 blocks of the same color.
|
||||
for (let r = 0; r < size - 1; r++) {
|
||||
for (let c = 0; c < size - 1; c++) {
|
||||
const v = at(r, c);
|
||||
if (v === at(r, c + 1) && v === at(r + 1, c) && v === at(r + 1, c + 1)) score += 3;
|
||||
}
|
||||
}
|
||||
// Rule 3: finder-like 1:1:3:1:1 patterns.
|
||||
const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
|
||||
const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
|
||||
const match = (get: (k: number) => number, start: number, pat: number[]) => {
|
||||
for (let k = 0; k < pat.length; k++) if (get(start + k) !== pat[k]) return false;
|
||||
return true;
|
||||
};
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c <= size - 11; c++) {
|
||||
if (match((k) => at(r, k), c, pat1) || match((k) => at(r, k), c, pat2)) score += 40;
|
||||
if (match((k) => at(k, r), c, pat1) || match((k) => at(k, r), c, pat2)) score += 40;
|
||||
}
|
||||
}
|
||||
// Rule 4: proportion of dark modules.
|
||||
let dark = 0;
|
||||
for (let i = 0; i < size * size; i++) dark += g.mod[i];
|
||||
const percent = (dark * 100) / (size * size);
|
||||
const k = Math.floor(Math.abs(percent - 50) / 5);
|
||||
score += k * 10;
|
||||
return score;
|
||||
}
|
||||
|
||||
function formatBits(mask: number): number {
|
||||
const data = (EC_BITS_L << 3) | mask; // 5 bits
|
||||
let rem = data << 10;
|
||||
for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
|
||||
return ((data << 10) | rem) ^ 0x5412;
|
||||
}
|
||||
|
||||
function drawFormat(g: Grid, mask: number): void {
|
||||
const size = g.size;
|
||||
const fmt = formatBits(mask);
|
||||
const bit = (i: number) => (fmt >> i) & 1;
|
||||
// First copy: around the top-left finder. Bits 0–5 run down column 8
|
||||
// (rows 0–5); bits 9–14 run left along row 8 (cols 5–0).
|
||||
for (let i = 0; i <= 5; i++) g.mod[i * size + 8] = bit(i);
|
||||
g.mod[7 * size + 8] = bit(6);
|
||||
g.mod[8 * size + 8] = bit(7);
|
||||
g.mod[8 * size + 7] = bit(8);
|
||||
for (let i = 9; i < 15; i++) g.mod[8 * size + (14 - i)] = bit(i);
|
||||
// Second copy: split across top-right and bottom-left.
|
||||
for (let i = 0; i < 8; i++) g.mod[(size - 1 - i) * size + 8] = bit(i);
|
||||
for (let i = 8; i < 15; i++) g.mod[8 * size + (size - 15 + i)] = bit(i);
|
||||
g.mod[(size - 8) * size + 8] = 1; // dark module stays set
|
||||
}
|
||||
|
||||
/** Build the final QR module matrix (true = dark) for `text`. */
|
||||
export function qrMatrix(text: string): boolean[][] {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const version = pickVersion(bytes.length);
|
||||
const codewords = toCodewords(bytes, version);
|
||||
const g = newGrid(17 + 4 * version);
|
||||
drawFunctionPatterns(g, version);
|
||||
placeData(g, codewords);
|
||||
|
||||
let best = 0;
|
||||
let bestScore = Infinity;
|
||||
for (let m = 0; m < 8; m++) {
|
||||
applyMask(g, m);
|
||||
drawFormat(g, m);
|
||||
const s = penalty(g);
|
||||
if (s < bestScore) {
|
||||
bestScore = s;
|
||||
best = m;
|
||||
}
|
||||
applyMask(g, m); // undo (XOR is its own inverse)
|
||||
}
|
||||
applyMask(g, best);
|
||||
drawFormat(g, best);
|
||||
|
||||
const out: boolean[][] = [];
|
||||
for (let r = 0; r < g.size; r++) {
|
||||
const row: boolean[] = [];
|
||||
for (let c = 0; c < g.size; c++) row.push(g.mod[r * g.size + c] === 1);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render `text` as an SVG QR code string (4-module quiet zone, viewBox only). */
|
||||
export function qrSvg(text: string): string {
|
||||
const m = qrMatrix(text);
|
||||
const size = m.length;
|
||||
const quiet = 4;
|
||||
const dim = size + quiet * 2;
|
||||
let rects = "";
|
||||
for (let r = 0; r < size; r++) {
|
||||
for (let c = 0; c < size; c++) {
|
||||
if (m[r][c]) rects += `<rect x="${c + quiet}" y="${r + quiet}" width="1" height="1"/>`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
|
||||
`shape-rendering="crispEdges" role="img" aria-label="QR code">` +
|
||||
`<rect width="${dim}" height="${dim}" fill="#ffffff"/>` +
|
||||
`<g fill="#000000">${rects}</g></svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Curated "Playground" scenarios surfaced in the config UI.
|
||||
*
|
||||
* Each scenario is a fixed, reviewable prompt template that the UI can dispatch
|
||||
* to a connected local coding agent (e.g. qwen-code), which then runs it in a
|
||||
* new terminal. Optional `{{inputs}}` are filled by the user before dispatch.
|
||||
*
|
||||
* Prompts are defined here and never accepted as free-form text from the web,
|
||||
* so the instruction handed to a local agent is always known and auditable.
|
||||
*/
|
||||
export interface ScenarioInput {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
prompt: string;
|
||||
inputs?: ScenarioInput[];
|
||||
}
|
||||
|
||||
export const SCENARIOS: Scenario[] = [
|
||||
// ---- 图像 ----
|
||||
{
|
||||
id: "image-generate",
|
||||
title: "文生图",
|
||||
description: "一键生成一张示例图片并保存到输出目录。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。",
|
||||
},
|
||||
{
|
||||
id: "image-describe",
|
||||
title: "图片理解",
|
||||
description: "从输出目录任选一张图片,详细描述内容与风格。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中任选一张图片,用中文详细描述它的内容、主体、构图、色彩与风格,并推测它适合的使用场景。若目录为空请说明。",
|
||||
},
|
||||
{
|
||||
id: "image-alt-batch",
|
||||
title: "批量 Alt 文本",
|
||||
description: "为输出目录下的图片批量生成无障碍 alt 文本。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请扫描输出目录(默认 output/images)下的所有图片,逐张生成简洁、准确的 alt 无障碍描述,最后以「文件名 → alt 文本」的表格汇总。若目录为空请说明。",
|
||||
},
|
||||
{
|
||||
id: "image-to-code",
|
||||
title: "截图转代码",
|
||||
description: "把输出目录里的界面截图还原成 HTML+CSS。",
|
||||
category: "图像",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中查找一张界面截图,用 HTML + CSS 尽可能还原它的布局、间距与配色,输出为一个可直接在浏览器打开的单文件,并简述还原思路。若没有找到截图请说明。",
|
||||
},
|
||||
// ---- 音频 ----
|
||||
{
|
||||
id: "speech-generate",
|
||||
title: "文字转语音",
|
||||
description: "把一句示例文字合成为自然语音。",
|
||||
category: "音频",
|
||||
prompt:
|
||||
"请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。",
|
||||
},
|
||||
{
|
||||
id: "audio-summarize",
|
||||
title: "音频转写总结",
|
||||
description: "转写输出目录里的音频并提炼要点。",
|
||||
category: "音频",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/speech)中找到一个音频文件,转写其内容,先给出完整文字,再用要点列表总结关键信息。若目录为空或缺少转写能力,请说明并尝试用可用的能力完成。",
|
||||
},
|
||||
// ---- 视频 ----
|
||||
{
|
||||
id: "video-generate",
|
||||
title: "文生视频",
|
||||
description: "一键生成一段示例短视频。",
|
||||
category: "视频",
|
||||
prompt:
|
||||
"请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。",
|
||||
},
|
||||
{
|
||||
id: "video-storyboard",
|
||||
title: "视频分镜脚本",
|
||||
description: "围绕示例主题产出可用于文生视频的分镜。",
|
||||
category: "视频",
|
||||
prompt:
|
||||
"围绕主题「城市清晨的第一杯咖啡」,为一支 15-30 秒的短视频撰写分镜脚本:逐镜头给出画面描述、时长、字幕或旁白,并为每个镜头附上可直接用于文生视频的英文 prompt。",
|
||||
},
|
||||
// ---- 多模态 ----
|
||||
{
|
||||
id: "media-prompt-craft",
|
||||
title: "多模态提示词",
|
||||
description: "把一个示例创意扩展成图/视频/语音提示词。",
|
||||
category: "多模态",
|
||||
prompt:
|
||||
"把创意「未来赛博城市的夜市」扩展成三组高质量生成提示词:1) 文生图;2) 文生视频;3) 语音风格描述。每组给出中英对照,并简要说明关键参数建议。",
|
||||
},
|
||||
{
|
||||
id: "image-story-narration",
|
||||
title: "图片配音文案",
|
||||
description: "为输出目录里的图片写解说词并给出可合成文本。",
|
||||
category: "多模态",
|
||||
prompt:
|
||||
"请在输出目录(默认 output/images)中任选一张图片,为它撰写一段 60 秒左右的中文解说词(适合配音),语气生动。随后给出可直接用于语音合成的纯文本版本。若目录为空请说明。",
|
||||
},
|
||||
// ---- 代码 ----
|
||||
{
|
||||
id: "summarize-project",
|
||||
title: "总结当前项目",
|
||||
description: "让 agent 阅读当前目录,总结架构、技术栈与主要模块。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请阅读当前工作目录的项目结构和关键源码,用简洁的中文总结:1) 它是做什么的;2) 技术栈;3) 主要模块及其职责;4) 值得注意的设计。先浏览再下结论,不要臆测。",
|
||||
},
|
||||
{
|
||||
id: "write-tests",
|
||||
title: "为核心模块写单测",
|
||||
description: "自动挑选缺测试的核心模块并补全单元测试。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请在当前项目中挑选一个核心且缺少测试(或测试薄弱)的模块,为它编写全面的单元测试,覆盖主要逻辑分支和边界情况,并遵循本项目现有的测试框架与风格。先阅读相关文件及其依赖,再编写测试。",
|
||||
},
|
||||
{
|
||||
id: "code-review",
|
||||
title: "代码审查",
|
||||
description: "审查当前项目核心代码,指出问题与改进建议。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请审查当前项目的核心源码,指出潜在的 bug、安全隐患、性能与可维护性问题,并给出具体、可操作的改进建议,按严重程度排序。先浏览项目结构,选取关键文件再审查。",
|
||||
},
|
||||
{
|
||||
id: "explain-code",
|
||||
title: "解释核心代码",
|
||||
description: "挑选入口或核心模块,解释其实现与依赖。",
|
||||
category: "代码",
|
||||
prompt:
|
||||
"请挑选当前项目的入口文件或核心模块,解释它的实现:职责是什么、关键流程如何运转、依赖了哪些模块。用清晰的中文说明,必要时给出调用关系。",
|
||||
},
|
||||
// ---- 文档 ----
|
||||
{
|
||||
id: "generate-readme",
|
||||
title: "生成 README",
|
||||
description: "阅读代码后生成结构清晰、与实现一致的 README.md。",
|
||||
category: "文档",
|
||||
prompt:
|
||||
"为当前工作目录的项目生成一个结构清晰的 README.md,包含:项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写,内容必须与实际实现一致。",
|
||||
},
|
||||
];
|
||||
|
||||
/** Look up a scenario by id, or undefined when unknown. */
|
||||
export function getScenario(id: string): Scenario | undefined {
|
||||
return SCENARIOS.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */
|
||||
export function renderScenarioPrompt(scenario: Scenario, values: Record<string, string>): string {
|
||||
return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
|
||||
const v = values[key];
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,12 @@ export default defineCommand({
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
|
||||
required: true,
|
||||
},
|
||||
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
|
||||
value: {
|
||||
type: "string",
|
||||
valueHint: "<value>",
|
||||
description: "Value to set",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: [
|
||||
"--key output --value json",
|
||||
@@ -44,7 +49,9 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
|
||||
await ctx.configStore.write({
|
||||
[resolvedKey]: coerced,
|
||||
} as Partial<ConfigFile>);
|
||||
|
||||
if (!settings.quiet) {
|
||||
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
|
||||
|
||||
@@ -32,6 +32,79 @@ export const SECRET_KEYS = new Set<string>([
|
||||
"security_token",
|
||||
]);
|
||||
|
||||
// The web UI edits the full ConfigFile, so it exposes these extra keys on top
|
||||
// of VALID_KEYS (which `config set` keeps as its narrower, documented surface).
|
||||
// This lets `config ui` surface and edit every field that lives in config.json
|
||||
// rather than silently hiding console/telemetry settings.
|
||||
export const UI_EXTRA_KEYS = [
|
||||
"console_site",
|
||||
"console_region",
|
||||
"console_switch_agent",
|
||||
"telemetry",
|
||||
] as const;
|
||||
|
||||
export const UI_VALID_KEYS = [...VALID_KEYS, ...UI_EXTRA_KEYS] as const;
|
||||
|
||||
// Keys the UI renders as a fixed-choice dropdown instead of a free-text input.
|
||||
export const UI_ENUM_KEYS: Record<string, string[]> = {
|
||||
output: ["text", "json"],
|
||||
console_site: ["domestic", "international"],
|
||||
};
|
||||
|
||||
// Keys the UI renders as a true/false dropdown and stores as a boolean.
|
||||
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
|
||||
|
||||
// Default model each `default_*_model` key falls back to when left unset. These
|
||||
// mirror the inline `|| "<model>"` fallbacks in the generation commands
|
||||
// (text/chat, image/generate, video/generate, speech/synthesize, omni/chat) and
|
||||
// are surfaced as input placeholders so users can see the effective default
|
||||
// without persisting a value that would pin the model.
|
||||
export const UI_MODEL_DEFAULTS: Record<string, string> = {
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_image_model: "qwen-image-2.0",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_speech_model: "cosyvoice-v3-flash",
|
||||
default_omni_model: "qwen3.5-omni-plus",
|
||||
};
|
||||
|
||||
/** One selectable model plus a short note on where the CLI uses it. */
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// A per-category catalog of the model names the `bl` pipeline actually
|
||||
// references (packages/runtime/src/pipeline/steps/bl-api.ts, plus the advisor
|
||||
// and agent-writer helpers). The UI groups these under each `default_*_model`
|
||||
// field as click-to-fill suggestions; the first entry is the fallback default.
|
||||
// Only names present in the codebase are listed here — no invented models.
|
||||
export const UI_MODEL_CATALOG: Record<string, ModelOption[]> = {
|
||||
default_text_model: [
|
||||
{ id: "qwen3.8-max", role: "text/chat default" },
|
||||
{ id: "qwen3-coder-plus", role: "coding-oriented (agent config)" },
|
||||
{ id: "qwen-flash", role: "fast · advisor ranking" },
|
||||
{ id: "qwen3.6-flash", role: "fast · advisor intent" },
|
||||
],
|
||||
default_image_model: [
|
||||
{ id: "qwen-image-2.0", role: "image/generate default · sync" },
|
||||
{ id: "qwen-image-max", role: "image/generate · sync" },
|
||||
{ id: "qwen-image-edit-2.0", role: "image/edit · sync" },
|
||||
{ id: "wanx2.x", role: "image/generate · async series" },
|
||||
],
|
||||
default_video_model: [
|
||||
{ id: "happyhorse-1.1-t2v", role: "video/generate default · text-to-video" },
|
||||
{ id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" },
|
||||
],
|
||||
default_speech_model: [
|
||||
{ id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" },
|
||||
{ id: "fun-asr", role: "speech/recognize (ASR)" },
|
||||
],
|
||||
default_omni_model: [
|
||||
{ id: "qwen3.5-omni-plus", role: "omni/chat default" },
|
||||
{ id: "qwen3-vl-plus", role: "vision/describe · multimodal input" },
|
||||
],
|
||||
};
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
|
||||
export const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
@@ -92,3 +165,55 @@ export function validateAndCoerce(key: string, value: string): string | number {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate/coerce a value for the wider set of keys the web UI can edit
|
||||
* (UI_VALID_KEYS). Standard keys delegate to `validateAndCoerce`; the UI-only
|
||||
* extras (console_*, telemetry) are validated here. Booleans are returned as
|
||||
* real booleans so they persist correctly in config.json.
|
||||
*/
|
||||
export function validateAndCoerceUi(key: string, value: string): string | number | boolean {
|
||||
const resolvedKey = resolveKey(key);
|
||||
|
||||
if ((VALID_KEYS as readonly string[]).includes(resolvedKey)) {
|
||||
return validateAndCoerce(key, value);
|
||||
}
|
||||
|
||||
if (resolvedKey === "console_site") {
|
||||
if (!["domestic", "international"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid console_site "${value}". Valid values: domestic, international`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (resolvedKey === "console_region") return value;
|
||||
|
||||
if (resolvedKey === "console_switch_agent") {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid console_switch_agent "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
if (resolvedKey === "telemetry") {
|
||||
if (value !== "true" && value !== "false") {
|
||||
throw new BailianError(
|
||||
`Invalid telemetry "${value}". Valid values: true, false`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${UI_VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,7 @@
|
||||
import http from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import {
|
||||
defineCommand,
|
||||
@@ -10,13 +12,38 @@ import {
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
deleteConfigProfile,
|
||||
REGIONS,
|
||||
type ConfigStore,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { listenLocalServer, openInBrowser, openPath } from "../shared/local-server.ts";
|
||||
import { PAGE_HTML } from "./ui-html.ts";
|
||||
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
import {
|
||||
UI_VALID_KEYS,
|
||||
UI_ENUM_KEYS,
|
||||
UI_BOOLEAN_KEYS,
|
||||
UI_MODEL_DEFAULTS,
|
||||
UI_MODEL_CATALOG,
|
||||
SECRET_KEYS,
|
||||
resolveKey,
|
||||
validateAndCoerceUi,
|
||||
} from "./shared.ts";
|
||||
import {
|
||||
listSkills,
|
||||
listMcpServers,
|
||||
listAgents,
|
||||
getSkillDetail,
|
||||
getAgentDetail,
|
||||
writeMcpServer,
|
||||
deleteMcpServer,
|
||||
installSkillZip,
|
||||
} from "./inventory.ts";
|
||||
import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts";
|
||||
import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts";
|
||||
import { qrSvg } from "./qr.ts";
|
||||
import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts";
|
||||
import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts";
|
||||
|
||||
const FLAGS = {
|
||||
port: {
|
||||
@@ -50,6 +77,7 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
@@ -59,16 +87,47 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Max size for binary uploads (skill .zip packages). */
|
||||
const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB
|
||||
|
||||
function readBodyBuffer(req: http.IncomingMessage, max: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > max) {
|
||||
reject(new Error("payload too large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Constant-time token comparison (avoids timing side channels). */
|
||||
function tokenMatches(provided: string | null, expected: string): boolean {
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Build the request cleaned/validated config block from a posted `data` map. */
|
||||
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
|
||||
const cleaned: Record<string, string | number> = {};
|
||||
function buildProfilePatch(
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, string | number | boolean> {
|
||||
const cleaned: Record<string, string | number | boolean> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
let value = "";
|
||||
if (typeof v === "string") value = v;
|
||||
else if (typeof v === "number" || typeof v === "boolean") value = String(v);
|
||||
// null/undefined/objects fall through as "" and clear the key
|
||||
if (value === "") continue;
|
||||
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
|
||||
cleaned[resolveKey(k)] = validateAndCoerceUi(k, value);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
@@ -76,9 +135,9 @@ function buildProfilePatch(data: Record<string, unknown>): Record<string, string
|
||||
/** Preserve valid Config fields that the UI does not expose or manage. */
|
||||
function mergeUnmanagedProfileFields(
|
||||
existing: Record<string, unknown>,
|
||||
managedPatch: Record<string, string | number>,
|
||||
managedPatch: Record<string, string | number | boolean>,
|
||||
): Record<string, unknown> {
|
||||
const managedKeys = new Set<string>(VALID_KEYS);
|
||||
const managedKeys = new Set<string>(UI_VALID_KEYS);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (!managedKeys.has(key)) merged[key] = value;
|
||||
@@ -91,7 +150,12 @@ function mergeUnmanagedProfileFields(
|
||||
* - Host header must be a loopback name (anti DNS-rebinding).
|
||||
* - every request must carry `?token=` matching the session token.
|
||||
*/
|
||||
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
|
||||
export function createConfigUiServer(
|
||||
token: string,
|
||||
configStore: ConfigStore,
|
||||
outputBase: string = defaultOutputBase(),
|
||||
authBridge?: AuthUiBridge,
|
||||
): http.Server {
|
||||
return http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0];
|
||||
@@ -102,7 +166,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("token") !== token) {
|
||||
if (!tokenMatches(u.searchParams.get("token"), token)) {
|
||||
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("unauthorized\n");
|
||||
return;
|
||||
@@ -112,17 +176,54 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
const path = u.pathname;
|
||||
|
||||
if (path === "/" && method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
// The page URL carries the session token, so never cache it.
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
||||
"img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " +
|
||||
"media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
});
|
||||
res.end(PAGE_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/qr" && method === "GET") {
|
||||
const data = (u.searchParams.get("data") ?? "").slice(0, 512);
|
||||
if (!data) {
|
||||
sendJson(res, 400, { error: "missing data" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const svg = qrSvg(data);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(svg);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/config" && method === "GET") {
|
||||
const profiles = configStore.profiles();
|
||||
sendJson(res, 200, {
|
||||
configFile: configStore.path,
|
||||
keys: VALID_KEYS,
|
||||
keys: UI_VALID_KEYS,
|
||||
secretKeys: [...SECRET_KEYS],
|
||||
enums: UI_ENUM_KEYS,
|
||||
booleanKeys: [...UI_BOOLEAN_KEYS],
|
||||
fieldDefaults: {
|
||||
...UI_MODEL_DEFAULTS,
|
||||
base_url: REGIONS.cn,
|
||||
output_dir: defaultOutputBase(),
|
||||
timeout: "300",
|
||||
},
|
||||
modelCatalog: UI_MODEL_CATALOG,
|
||||
activeProfile: profiles.active,
|
||||
default: profiles.default,
|
||||
named: profiles.named,
|
||||
@@ -130,6 +231,342 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skills" && method === "GET") {
|
||||
sendJson(res, 200, { skills: listSkills() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skill" && method === "GET") {
|
||||
const detail = getSkillDetail(u.searchParams.get("id") ?? "");
|
||||
if (!detail) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/skill/install" && method === "POST") {
|
||||
const source = u.searchParams.get("source") ?? "";
|
||||
const name = u.searchParams.get("name") ?? "";
|
||||
try {
|
||||
const buf = await readBodyBuffer(req, MAX_UPLOAD);
|
||||
const result = installSkillZip(source, buf, name);
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "GET") {
|
||||
sendJson(res, 200, { servers: listMcpServers() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as {
|
||||
source?: unknown;
|
||||
scope?: unknown;
|
||||
name?: unknown;
|
||||
config?: unknown;
|
||||
};
|
||||
const source = typeof body.source === "string" ? body.source : "";
|
||||
const scope = typeof body.scope === "string" && body.scope ? body.scope : "global";
|
||||
const name = typeof body.name === "string" ? body.name : "";
|
||||
try {
|
||||
writeMcpServer(source, scope, name, body.config);
|
||||
sendJson(res, 200, { saved: name.trim() });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/mcp" && method === "DELETE") {
|
||||
const source = u.searchParams.get("source") ?? "";
|
||||
const scope = u.searchParams.get("scope") || "global";
|
||||
const name = u.searchParams.get("name") ?? "";
|
||||
try {
|
||||
deleteMcpServer(source, scope, name);
|
||||
sendJson(res, 200, { deleted: name });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/health" && method === "GET") {
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
sendJson(res, 200, {
|
||||
node: process.version,
|
||||
nodeOk: Number.isFinite(major) && major >= 18,
|
||||
platform: process.platform,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agents" && method === "GET") {
|
||||
// Augment each agent with `launchable`: whether its CLI binary is on
|
||||
// PATH. "Connected" only means bl is wired into the agent's config, so
|
||||
// the UI uses this to avoid offering a launch that would instantly fail.
|
||||
// `dispatchable` additionally requires a verified prompt contract.
|
||||
const agents = listAgents();
|
||||
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
|
||||
sendJson(res, 200, {
|
||||
agents: agents.map((a, i) => ({
|
||||
...a,
|
||||
launchable: launchable[i],
|
||||
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent" && method === "GET") {
|
||||
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
|
||||
if (!detail) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/open" && method === "POST") {
|
||||
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
|
||||
const target = u.searchParams.get("path") ?? "";
|
||||
const allowed = detail?.settings.some((s) => s.path === target) ?? false;
|
||||
if (!detail || !allowed || !existsSync(target)) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openPath(target);
|
||||
sendJson(res, 200, { opened: target });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/scenarios" && method === "GET") {
|
||||
// Curated Playground scenarios plus the connected agents that can be
|
||||
// dispatched a prompt right now (on PATH + verified prompt contract).
|
||||
const agents = listAgents();
|
||||
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
|
||||
const targets = agents
|
||||
.map((a, i) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
|
||||
}))
|
||||
.filter((a) => a.dispatchable);
|
||||
sendJson(res, 200, { scenarios: SCENARIOS, agents: targets });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/status" && method === "GET") {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
authBridge
|
||||
? authBridge.status()
|
||||
: {
|
||||
authenticated: false,
|
||||
methods: { apiKey: false, console: false, openapi: false },
|
||||
primary: null,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" && method === "POST") {
|
||||
if (!authBridge) {
|
||||
sendJson(res, 400, { error: "login unavailable" });
|
||||
return;
|
||||
}
|
||||
authBridge.startConsoleLogin();
|
||||
sendJson(res, 200, { started: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout" && method === "POST") {
|
||||
if (!authBridge) {
|
||||
sendJson(res, 400, { error: "logout unavailable" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loggedOut = await authBridge.logout();
|
||||
sendJson(res, 200, { loggedOut });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/assets" && method === "GET") {
|
||||
sendJson(res, 200, listAssets(outputBase));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset/file" && method === "GET") {
|
||||
const abs = resolveAssetPath(outputBase, u.searchParams.get("path") ?? "");
|
||||
const st = abs && existsSync(abs) ? statSync(abs) : null;
|
||||
if (!abs || !st || !st.isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": contentType(extname(abs)),
|
||||
"Content-Length": st.size,
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
const stream = createReadStream(abs);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset" && method === "DELETE") {
|
||||
const rel = u.searchParams.get("path") ?? "";
|
||||
const abs = resolveAssetPath(outputBase, rel);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
unlinkSync(abs);
|
||||
sendJson(res, 200, { deleted: rel });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/asset/open" && method === "POST") {
|
||||
const rel = u.searchParams.get("path") ?? "";
|
||||
const abs = resolveAssetPath(outputBase, rel);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openPath(abs);
|
||||
sendJson(res, 200, { opened: rel });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/launch" && method === "POST") {
|
||||
try {
|
||||
const result = await launchAgent(u.searchParams.get("id") ?? "");
|
||||
sendJson(res, 200, result);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/agent/dispatch" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as {
|
||||
scenario?: unknown;
|
||||
agent?: unknown;
|
||||
values?: unknown;
|
||||
custom?: unknown;
|
||||
};
|
||||
const agentId = typeof body.agent === "string" ? body.agent : "";
|
||||
if (!agentSupportsPrompt(agentId)) {
|
||||
sendJson(res, 400, { error: "agent cannot be dispatched a prompt" });
|
||||
return;
|
||||
}
|
||||
let scenario: Scenario | undefined;
|
||||
const custom = body.custom;
|
||||
if (custom && typeof custom === "object" && !Array.isArray(custom)) {
|
||||
const c = custom as { title?: unknown; prompt?: unknown; inputs?: unknown };
|
||||
const promptTpl = typeof c.prompt === "string" ? c.prompt.trim() : "";
|
||||
if (!promptTpl) {
|
||||
sendJson(res, 400, { error: "custom scenario needs a prompt" });
|
||||
return;
|
||||
}
|
||||
const inputs: { key: string; label: string }[] = [];
|
||||
if (Array.isArray(c.inputs)) {
|
||||
for (const it of c.inputs as unknown[]) {
|
||||
if (it && typeof it === "object") {
|
||||
const o = it as { key?: unknown; label?: unknown };
|
||||
const key = typeof o.key === "string" ? o.key.trim() : "";
|
||||
if (key) {
|
||||
const label =
|
||||
typeof o.label === "string" && o.label.trim() ? o.label.trim() : key;
|
||||
inputs.push({ key, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scenario = {
|
||||
id: "custom",
|
||||
title: typeof c.title === "string" && c.title.trim() ? c.title.trim() : "Custom",
|
||||
description: "",
|
||||
category: "\u81ea\u5b9a\u4e49",
|
||||
prompt: promptTpl,
|
||||
inputs,
|
||||
};
|
||||
} else {
|
||||
scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined;
|
||||
}
|
||||
if (!scenario) {
|
||||
sendJson(res, 400, { error: "unknown scenario" });
|
||||
return;
|
||||
}
|
||||
const values: Record<string, string> = {};
|
||||
if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) {
|
||||
for (const [k, v] of Object.entries(body.values as Record<string, unknown>)) {
|
||||
if (typeof v === "string") values[k] = v;
|
||||
}
|
||||
}
|
||||
for (const inp of scenario.inputs ?? []) {
|
||||
if (!values[inp.key] || !values[inp.key]!.trim()) {
|
||||
sendJson(res, 400, { error: `Missing input: ${inp.label}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const prompt = renderScenarioPrompt(scenario, values);
|
||||
try {
|
||||
const result = await launchAgent(agentId, process.cwd(), prompt);
|
||||
sendJson(res, 200, {
|
||||
launched: true,
|
||||
agent: agentId,
|
||||
scenario: scenario.id,
|
||||
command: result.command,
|
||||
});
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/active" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
@@ -164,7 +601,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
return;
|
||||
}
|
||||
let normalized: string | undefined;
|
||||
let cleaned: Record<string, string | number>;
|
||||
let cleaned: Record<string, string | number | boolean>;
|
||||
try {
|
||||
normalized = normalizeConfigName(body.name);
|
||||
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
|
||||
@@ -191,9 +628,15 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found\n");
|
||||
} catch {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
} catch (err) {
|
||||
// Log server-side so failures are diagnosable, and return a JSON error
|
||||
// instead of an empty 500 body.
|
||||
console.error("[config ui] request failed:", err);
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, { error: errMessage(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -217,9 +660,29 @@ export default defineCommand({
|
||||
routes: [
|
||||
"GET / -> web UI",
|
||||
"GET /api/config -> read all profiles",
|
||||
"GET /api/skills -> list installed agent skills",
|
||||
"GET /api/skill -> read one skill's SKILL.md detail",
|
||||
"POST /api/skill/install -> install a skill from an uploaded .zip into a skills root",
|
||||
"GET /api/mcp -> list local MCP servers",
|
||||
"POST /api/mcp -> create or update one MCP server (writes its source config)",
|
||||
"DELETE /api/mcp -> remove one MCP server from its source config",
|
||||
"GET /api/health -> runtime environment info (node, platform, cwd)",
|
||||
"GET /api/agents -> list coding agent frameworks",
|
||||
"GET /api/agent -> one agent's config detail (secrets masked)",
|
||||
"POST /api/agent/open -> open one agent's config file with the OS default app",
|
||||
"GET /api/auth/status -> current auth state",
|
||||
"POST /api/auth/login -> start console login (opens browser)",
|
||||
"POST /api/auth/logout -> clear all stored credentials",
|
||||
"GET /api/assets -> list generated assets",
|
||||
"GET /api/asset/file -> stream one asset file",
|
||||
"POST /api/asset/open -> open one asset with the OS default app",
|
||||
"POST /api/agent/launch -> launch a coding agent CLI in a new terminal",
|
||||
"GET /api/scenarios -> list Playground scenarios and dispatchable agents",
|
||||
"POST /api/agent/dispatch -> dispatch a scenario prompt to a connected agent",
|
||||
"POST /api/profile -> save a profile",
|
||||
"POST /api/active -> activate a profile",
|
||||
"DELETE /api/profile -> delete a named profile",
|
||||
"DELETE /api/asset -> delete one asset file",
|
||||
],
|
||||
},
|
||||
format,
|
||||
@@ -228,7 +691,8 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const server = createConfigUiServer(token, ctx.configStore);
|
||||
const outputBase = settings.outputDir || defaultOutputBase();
|
||||
const server = createConfigUiServer(token, ctx.configStore, outputBase, makeAuthUiBridge(ctx));
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
resolveOutputDir,
|
||||
@@ -20,6 +18,7 @@ import {
|
||||
BailianError,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageEditApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
@@ -32,17 +31,6 @@ import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const EDIT_FLAGS = {
|
||||
image: {
|
||||
type: "array",
|
||||
@@ -77,6 +65,12 @@ const EDIT_FLAGS = {
|
||||
valueHint: "<text>",
|
||||
description: "Negative prompt to exclude unwanted content",
|
||||
},
|
||||
function: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description:
|
||||
"wanx*-imageedit function (default: description_edit). Examples: stylization_all, description_edit",
|
||||
},
|
||||
promptExtend: {
|
||||
type: "boolean",
|
||||
valueHint: "<bool>",
|
||||
@@ -114,6 +108,8 @@ export default defineCommand({
|
||||
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
|
||||
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
|
||||
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
|
||||
'--image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview',
|
||||
'--image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all',
|
||||
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -128,7 +124,7 @@ 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(
|
||||
@@ -138,71 +134,128 @@ export default defineCommand({
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
// Build content: all images first, then text prompt
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(u: string) => ({ image: u }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
size: resolveImageSize(flags.size, useSync),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size: resolveImageSize(flags.size, route.sizeProfile),
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
let body: DashScopeImageRequest;
|
||||
if (route.inputStyle === "function-base-image") {
|
||||
const baseImageUrl = resolvedImages[0];
|
||||
if (!baseImageUrl) {
|
||||
throw new BailianError(
|
||||
"wanx*-imageedit requires at least one --image as base_image_url.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
function: flags.function || "description_edit",
|
||||
prompt,
|
||||
base_image_url: baseImageUrl,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else if (route.inputStyle === "prompt-images") {
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
images: resolvedImages,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
};
|
||||
} else {
|
||||
const contentItems: Array<{ image?: string; text?: string }> = resolvedImages.map(
|
||||
(imageUrl: string) => ({ image: imageUrl }),
|
||||
);
|
||||
contentItems.push({ text: prompt });
|
||||
body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: contentItems,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Remove undefined parameters
|
||||
stripUndefined(body.parameters as Record<string, unknown>);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
const previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format);
|
||||
let previewBody: DashScopeImageRequest = body;
|
||||
if ("messages" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
} else if ("images" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
images: body.input.images?.map((imageUrl) => redactDataUri(imageUrl)),
|
||||
},
|
||||
};
|
||||
} else if ("base_image_url" in body.input) {
|
||||
previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
base_image_url: redactDataUri(body.input.base_image_url),
|
||||
mask_image_url: body.input.mask_image_url
|
||||
? redactDataUri(body.input.mask_image_url)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
emitResult(
|
||||
{ request: previewBody, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(
|
||||
`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}] [Images: ${resolvedImages.length}]\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const concurrent = getConcurrency(flags);
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -210,6 +263,7 @@ export default defineCommand({
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -217,15 +271,15 @@ async function handleSyncMode(
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({
|
||||
path: imageSyncPath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -239,6 +293,7 @@ async function handleSyncMode(
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: EditFlags,
|
||||
format: OutputFormat,
|
||||
@@ -249,14 +304,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
if (flags.async) {
|
||||
emitResult({ task_ids: taskIds }, format);
|
||||
@@ -269,12 +324,12 @@ async function handleAsyncMode(
|
||||
url: client.url(taskPath(taskId)),
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -285,13 +340,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -321,8 +376,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
defineCommand,
|
||||
imagePath,
|
||||
imageSyncPath,
|
||||
taskPath,
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
generateFilename,
|
||||
resolveBooleanFlag,
|
||||
resolveWatermark,
|
||||
resolveImageGenerateApi,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
} from "bailian-cli-core";
|
||||
@@ -31,18 +30,6 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint.
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const GENERATE_FLAGS = {
|
||||
prompt: { type: "string", valueHint: "<text>", description: "Image description", required: true },
|
||||
model: {
|
||||
@@ -109,6 +96,8 @@ export default defineCommand({
|
||||
'--prompt "Logo" --watermark false',
|
||||
'--prompt "An alien in the space" --watermark false',
|
||||
'--prompt "sunset" --model wan2.6-t2i --async --quiet',
|
||||
'--prompt "plush doll" --model z-image-turbo --size 1024*1024',
|
||||
'--prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024',
|
||||
'--prompt "Pro quality" --model qwen-image-2.0-pro',
|
||||
'--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel',
|
||||
],
|
||||
@@ -117,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,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
route.promptExtendDefault,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
|
||||
const body: DashScopeImageRequest = {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
|
||||
size,
|
||||
n,
|
||||
seed: flags.seed,
|
||||
prompt_extend: promptExtend,
|
||||
watermark,
|
||||
};
|
||||
|
||||
const body: DashScopeImageRequest =
|
||||
route.inputStyle === "prompt"
|
||||
? {
|
||||
model,
|
||||
input: {
|
||||
prompt,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
parameters,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
input: {
|
||||
messages: [{ role: "user", content: [{ text: prompt }] }],
|
||||
},
|
||||
parameters: {
|
||||
...parameters,
|
||||
negative_prompt: flags.negativePrompt || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
emitResult(
|
||||
{ request: body, mode: route.useSync ? "sync" : "async", path: route.path },
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.quiet) {
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${useSync ? "sync" : "async"}]\n`);
|
||||
process.stderr.write(`[Model: ${model}] [Mode: ${route.useSync ? "sync" : "async"}]\n`);
|
||||
}
|
||||
|
||||
if (useSync) {
|
||||
await handleSyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
if (route.useSync) {
|
||||
await handleSyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
} else {
|
||||
await handleAsyncMode(ctx.client, settings, model, body, flags, format, concurrent);
|
||||
await handleAsyncMode(ctx.client, settings, route.path, body, flags, format, concurrent);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Sync mode: qwen-image-2.0 series ----
|
||||
// ---- Sync mode: qwen-image / wan2.7-image / z-image ----
|
||||
|
||||
async function handleSyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
concurrent: number,
|
||||
): Promise<void> {
|
||||
const results = await runConcurrent(concurrent, settings, () =>
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path: imageSyncPath(), method: "POST", body }),
|
||||
client.requestJson<DashScopeImageSyncResponse>({ path, method: "POST", body }),
|
||||
);
|
||||
|
||||
const imageUrls = results
|
||||
.flatMap((r) => r.output.choices || [])
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((result) => result.output.choices || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -194,12 +200,12 @@ async function handleSyncMode(
|
||||
await saveImages(imageUrls, flags, settings, format);
|
||||
}
|
||||
|
||||
// ---- Async mode: wan2.x / qwen-image-plus ----
|
||||
// ---- Async mode: wan2.6-t2i / wan2.6-image / legacy text2image ----
|
||||
|
||||
async function handleAsyncMode(
|
||||
client: Client,
|
||||
settings: Settings,
|
||||
_model: string,
|
||||
path: string,
|
||||
body: DashScopeImageRequest,
|
||||
flags: GenerateFlags,
|
||||
format: string,
|
||||
@@ -210,14 +216,14 @@ async function handleAsyncMode(
|
||||
settings,
|
||||
() =>
|
||||
client.requestJson<DashScopeAsyncResponse>({
|
||||
path: imagePath(),
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
async: true,
|
||||
}),
|
||||
"tasks",
|
||||
);
|
||||
const taskIds = responses.map((r) => r.output.task_id);
|
||||
const taskIds = responses.map((response) => response.output.task_id);
|
||||
|
||||
// --async: return all task IDs immediately
|
||||
if (flags.async) {
|
||||
@@ -234,12 +240,12 @@ async function handleAsyncMode(
|
||||
url: pollUrl,
|
||||
intervalSec: pollInterval,
|
||||
timeoutSec: settings.timeout,
|
||||
isComplete: (d) => (d as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (d) => (d as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (d) => (d as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (d) => {
|
||||
const o = (d as DashScopeTaskResponse).output;
|
||||
return o.message || o.code || undefined;
|
||||
isComplete: (data) => (data as DashScopeTaskResponse).output.task_status === "SUCCEEDED",
|
||||
isFailed: (data) => (data as DashScopeTaskResponse).output.task_status === "FAILED",
|
||||
getStatus: (data) => (data as DashScopeTaskResponse).output.task_status,
|
||||
getErrorMessage: (data) => {
|
||||
const output = (data as DashScopeTaskResponse).output;
|
||||
return output.message || output.code || undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -250,13 +256,13 @@ async function handleAsyncMode(
|
||||
for (const result of results) {
|
||||
if (result.output.choices) {
|
||||
const urls = result.output.choices
|
||||
.flatMap((c) => c.message?.content || [])
|
||||
.flatMap((choice) => choice.message?.content || [])
|
||||
.map((item) => item.image)
|
||||
.filter(Boolean);
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
if (result.output.results) {
|
||||
const urls = result.output.results.map((r) => r.url).filter(Boolean);
|
||||
const urls = result.output.results.map((item) => item.url).filter(Boolean);
|
||||
if (urls.length > 0 && imageUrls.length === 0) {
|
||||
imageUrls.push(...urls);
|
||||
}
|
||||
@@ -298,8 +304,8 @@ async function saveImages(
|
||||
// Parallel download all images
|
||||
const items =
|
||||
imageUrls.length > 1
|
||||
? imageUrls.map((url, i) => {
|
||||
const filename = `${prefix}_${String(i + 1).padStart(3, "0")}.png`;
|
||||
? imageUrls.map((url, index) => {
|
||||
const filename = `${prefix}_${String(index + 1).padStart(3, "0")}.png`;
|
||||
return { url, destPath: join(outDir, filename) };
|
||||
})
|
||||
: [{ url: imageUrls[0], destPath: join(outDir, `${prefix}.png`) }];
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ResourceAddress } from "@openagentpack/sdk";
|
||||
|
||||
/** Full state address: provider.type.name */
|
||||
export function formatResourceAddress(address: ResourceAddress): string {
|
||||
return `${address.provider}.${address.type}.${address.name}`;
|
||||
}
|
||||
|
||||
/** CLI display short label: type.name (provider) */
|
||||
export function formatResourceLabel(address: ResourceAddress): string {
|
||||
return `${address.type}.${address.name} (${address.provider})`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
createProjectRuntime,
|
||||
type LoadedProjectConfig,
|
||||
type ProjectRuntimeContext,
|
||||
resolveProjectConfig,
|
||||
UserError,
|
||||
} from "@openagentpack/sdk";
|
||||
import {
|
||||
assertProviderCredentials,
|
||||
type CredentialHost,
|
||||
injectProviderCredentials,
|
||||
normalizeInterpolatedProviderBlocks,
|
||||
prepareProviderEnv,
|
||||
scrubCredentialEnv,
|
||||
} from "./credentials.ts";
|
||||
import { loadFileState } from "./file-state-manager.ts";
|
||||
import { type HostContext, installSdkTransport } from "./transport.ts";
|
||||
|
||||
export { CREDENTIALS_NOTE, OFFLINE_NOTE } from "./credentials.ts";
|
||||
|
||||
/**
|
||||
* Whether this run requires provider keys:
|
||||
* - "all" (default) — online command: every provider declared in agents.yaml
|
||||
* must have a non-empty key after injection
|
||||
* - "none" — offline command (local config/state only), skip the check
|
||||
*/
|
||||
export type CredentialScope = "all" | "none";
|
||||
|
||||
interface AgentConfigOptions {
|
||||
resolveEnv?: boolean;
|
||||
projectName?: string;
|
||||
statePath?: string;
|
||||
credentials?: CredentialScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve agents.yaml with credentials injected the bl way and scrubbed from the
|
||||
* environment — the shared credential spine for every SDK-engine command:
|
||||
* 1. prepare env (SDK bootstrap for non-bailian + placeholders so interpolation
|
||||
* never throws on a value we're about to supply/reject)
|
||||
* 2. resolve + interpolate the config
|
||||
* 3. override the bailian block with the CLI auth chain's credential (in-memory)
|
||||
* 4. scrub all credential vars from process.env (real values now live only in
|
||||
* the config object → provider adapters, never the environment)
|
||||
* 5. fail with a CLI-authoritative AUTH error if any provider's key is empty
|
||||
* (offline commands pass `credentials: "none"` to skip the check)
|
||||
*/
|
||||
export async function resolveAgentProjectConfig(
|
||||
host: CredentialHost,
|
||||
filePath: string,
|
||||
options: AgentConfigOptions = {},
|
||||
): Promise<LoadedProjectConfig> {
|
||||
prepareProviderEnv();
|
||||
const resolved = await resolveProjectConfig(filePath, options);
|
||||
normalizeInterpolatedProviderBlocks(resolved.config.providers);
|
||||
injectProviderCredentials(resolved.config.providers, host);
|
||||
scrubCredentialEnv();
|
||||
if ((options.credentials ?? "all") !== "none") {
|
||||
assertProviderCredentials(resolved.config.providers);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full ProjectRuntimeContext from a config file path — the standard
|
||||
* entry point for agent commands that need the SDK engine. Mirrors OpenAgentPack
|
||||
* CLI's buildCliRuntime: resolve config → load local state → assemble runtime.
|
||||
* Takes the host context first so every SDK-engine command wires the
|
||||
* instrumented transport (UA / tracking headers / verbose) and the bl-resolved,
|
||||
* in-memory-injected credential ({@link resolveAgentProjectConfig}) by construction.
|
||||
*/
|
||||
export async function buildAgentRuntime(
|
||||
host: HostContext & CredentialHost,
|
||||
filePath: string,
|
||||
options: AgentConfigOptions = {},
|
||||
): Promise<ProjectRuntimeContext & { configPath: string }> {
|
||||
installSdkTransport(host);
|
||||
const { config, configPath, projectName } = await resolveAgentProjectConfig(
|
||||
host,
|
||||
filePath,
|
||||
options,
|
||||
);
|
||||
const state = await loadFileState(configPath, options.statePath, projectName);
|
||||
const ctx = createProjectRuntime({
|
||||
projectName,
|
||||
config,
|
||||
state,
|
||||
configPath,
|
||||
providers: config.providers,
|
||||
});
|
||||
return { ...ctx, configPath };
|
||||
}
|
||||
|
||||
/** Ensure a user-supplied --provider value is actually configured in agents.yaml. */
|
||||
export function assertProviderConfigured(
|
||||
ctx: ProjectRuntimeContext,
|
||||
provider: string | undefined,
|
||||
): void {
|
||||
if (!provider || provider === "all") return;
|
||||
if (ctx.providers.has(provider)) return;
|
||||
const available = Array.from(ctx.providers.keys()).join(", ") || "none";
|
||||
throw new UserError(
|
||||
`Provider '${provider}' is not configured. Available providers: ${available}.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Redirect `console.log` / `console.info` to stderr while `fn` runs.
|
||||
*
|
||||
* The OpenAgentPack SDK's provider adapters emit progress/debug logging via
|
||||
* `console.log` (e.g. `[skill-upload]`), which would corrupt bl's stdout data
|
||||
* channel in `--output json` mode. Wrapping SDK calls that may log keeps stdout
|
||||
* a clean data channel. Restores the originals on completion.
|
||||
*/
|
||||
export async function withStdoutProtected<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const originalLog = console.log;
|
||||
const originalInfo = console.info;
|
||||
const toStderr = (...args: unknown[]): void => {
|
||||
process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`);
|
||||
};
|
||||
console.log = toStderr;
|
||||
console.info = toStderr;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.info = originalInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { AGENTS_PROVIDER_FIELDS, bootstrapRuntimeCredentialsSync } from "@openagentpack/sdk";
|
||||
import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* AgentStudio API path the SDK's BailianClient serves resources under. bl's
|
||||
* `base_url` is the bare model-service origin (e.g. https://dashscope.aliyuncs.com);
|
||||
* the SDK appends resource paths onto the bailian provider's `base_url` verbatim,
|
||||
* so the agent path must carry this suffix. See OpenAgentPack BailianClient.
|
||||
*/
|
||||
const AGENTSTUDIO_API_PATH = "/api/v1/agentstudio";
|
||||
|
||||
/**
|
||||
* Every env var the SDK recognizes as provider credential material (primary keys
|
||||
* from the SDK's own field map) plus bl-side interpolation aliases and bailian's
|
||||
* endpoint var (not part of AGENTS_PROVIDER_FIELDS). These are the only vars the
|
||||
* pipeline placeholders (to keep interpolation from throwing) and scrubs (so no
|
||||
* real credential persists in the environment).
|
||||
*/
|
||||
const CREDENTIAL_ENV_KEYS = [
|
||||
...new Set([
|
||||
...Object.values(AGENTS_PROVIDER_FIELDS).flatMap((fields) => fields.map((field) => field.key)),
|
||||
"BAILIAN_API_KEY",
|
||||
"BAILIAN_BASE_URL",
|
||||
"CLAUDE_API_KEY",
|
||||
"QODER_API_KEY",
|
||||
]),
|
||||
];
|
||||
|
||||
/** How to obtain each provider's key, surfaced in the CLI's own AUTH error when it is missing. */
|
||||
const CREDENTIAL_HINTS: Record<string, string> = {
|
||||
bailian: "Run `bl auth login --api-key <key>`, pass --api-key, or set DASHSCOPE_API_KEY.",
|
||||
claude: "Set ANTHROPIC_API_KEY (or CLAUDE_API_KEY) in your shell or .env.",
|
||||
ark: "Set ARK_API_KEY in your shell or .env.",
|
||||
qoder: "Set QODER_PAT (or QODER_API_KEY) in your shell or .env.",
|
||||
};
|
||||
|
||||
/** The slice of CommandContext the credential pipeline needs: authStage-resolved client + settings. */
|
||||
export interface CredentialHost {
|
||||
client: Client;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared `--help` note documenting where agent commands get provider
|
||||
* credentials. Bailian goes through bl's own auth chain (commands declare
|
||||
* `auth: "apiKey"`); other providers come from env. Either way the resolved
|
||||
* credential is injected into the SDK in-memory and scrubbed from the
|
||||
* environment. Attach to every command that loads agents.yaml. `bl` prefix is
|
||||
* safe: agent commands ship on `bl` only.
|
||||
*/
|
||||
export const CREDENTIALS_NOTE = [
|
||||
"Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).",
|
||||
"Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.",
|
||||
"Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared `--help` note for commands that never talk to a provider: they load
|
||||
* agents.yaml / local state only, so no login or provider key is required.
|
||||
*/
|
||||
export const OFFLINE_NOTE = [
|
||||
"Runs fully offline against local files: no login or provider credentials required.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Load the SDK's env-based credential sources (`.env`, `~/.agents/config.json`)
|
||||
* for non-bailian providers, then placeholder every credential var that is still
|
||||
* unset with "" so agents.yaml `${VAR}` interpolation never throws on a value the
|
||||
* pipeline is about to supply (bailian) or authoritatively reject ({@link
|
||||
* assertProviderCredentials}). Runs every call (no I/O cache) so a scrubbed
|
||||
* environment is repopulated if the same process resolves more than one config.
|
||||
*/
|
||||
export function prepareProviderEnv(): void {
|
||||
bootstrapRuntimeCredentialsSync();
|
||||
for (const key of CREDENTIAL_ENV_KEYS) {
|
||||
if (process.env[key] === undefined) process.env[key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the bailian provider block with bl's authStage-resolved credential, so
|
||||
* the bailian API key is authoritatively the CLI auth chain's — never a config
|
||||
* file bare-read or a stale env value. `api_key` is replaced unconditionally
|
||||
* when a credential resolved; `base_url` / `workspace_id` are filled only when
|
||||
* the block references them and the interpolated value is empty (a literal in
|
||||
* agents.yaml is respected).
|
||||
*
|
||||
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
|
||||
* paths onto it verbatim; a value already ending in the suffix is left as-is.
|
||||
* It is filled even without a credential — `client.baseUrl` is readable
|
||||
* credential-less (defaults to the CLI's model-domain base URL) — so offline
|
||||
* commands (which skip the credential assert) still satisfy the SDK's
|
||||
* "workspace_id or base_url" schema. With no credential the `api_key` is left
|
||||
* untouched: online commands reject it via {@link assertProviderCredentials}.
|
||||
*/
|
||||
export function injectProviderCredentials(
|
||||
providers: Record<string, unknown>,
|
||||
host: CredentialHost,
|
||||
): void {
|
||||
const bailian = providers.bailian;
|
||||
if (!bailian || typeof bailian !== "object") return;
|
||||
const block = bailian as Record<string, unknown>;
|
||||
|
||||
const cred = host.client.exportApiCredential();
|
||||
if (cred) block.api_key = cred.token;
|
||||
if ("base_url" in block && !block.base_url) {
|
||||
// Defensive normalization: the auth chain already normalizes base_url to
|
||||
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
|
||||
const origin = host.client.baseUrl.replace(/\/+$/, "");
|
||||
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
|
||||
? origin
|
||||
: `${origin}${AGENTSTUDIO_API_PATH}`;
|
||||
}
|
||||
if ("workspace_id" in block && !block.workspace_id && host.settings.workspaceId) {
|
||||
block.workspace_id = host.settings.workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every credential var from `process.env` after interpolation has run and
|
||||
* bailian has been overridden in-memory. From here on the real credentials live
|
||||
* only in the config object (and, after `createProjectRuntime`, in each provider
|
||||
* adapter instance) — nothing persists in the environment for the process
|
||||
* lifetime or any child process.
|
||||
*/
|
||||
export function scrubCredentialEnv(): void {
|
||||
for (const key of CREDENTIAL_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK interpolates `${VAR}` into the raw YAML text, so an empty env var
|
||||
* leaves `api_key:` with nothing after it — YAML parses that as null. Normalize
|
||||
* every null provider field back to "" so the pipeline stays uniform: for
|
||||
* online commands an empty api_key is caught by {@link
|
||||
* assertProviderCredentials}; for offline commands (which skip the assert) the
|
||||
* blocks still satisfy the SDK's string schemas instead of failing zod with
|
||||
* "received null" before the run even starts.
|
||||
*/
|
||||
export function normalizeInterpolatedProviderBlocks(providers: Record<string, unknown>): void {
|
||||
for (const raw of Object.values(providers)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const block = raw as Record<string, unknown>;
|
||||
for (const [fieldName, value] of Object.entries(block)) {
|
||||
if (value === null) block[fieldName] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After injection, fail with a CLI-authoritative AUTH error if any configured
|
||||
* provider's `api_key` resolved empty (missing env var, or no bl login for
|
||||
* bailian). Replaces the SDK's raw `Environment variable '...' is not set` /
|
||||
* zod config error with a clean message plus a provider-specific hint. Validates
|
||||
* every declared provider, so a project is only runnable once all its providers'
|
||||
* keys are available; offline commands skip the check entirely.
|
||||
*/
|
||||
export function assertProviderCredentials(providers: Record<string, unknown>): void {
|
||||
for (const [name, raw] of Object.entries(providers)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const block = raw as Record<string, unknown>;
|
||||
if (!("api_key" in block)) continue;
|
||||
const apiKey = block.api_key;
|
||||
if (typeof apiKey === "string" && apiKey.trim()) continue;
|
||||
throw new BailianError(
|
||||
`Provider '${name}' is configured but its API key is empty.`,
|
||||
ExitCode.AUTH,
|
||||
CREDENTIAL_HINTS[name] ?? `Provide credentials for provider '${name}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { UserError } from "@openagentpack/sdk";
|
||||
import { type ApiErrorBody, BailianError, ExitCode, mapApiError } from "bailian-cli-core";
|
||||
|
||||
/**
|
||||
* Structural shape of the SDK's `ApiError` (thrown by provider clients on HTTP
|
||||
* 4xx/5xx). Matched on fields instead of `instanceof` because the installed SDK
|
||||
* version does not export the class yet, and structural matching keeps this
|
||||
* check stable across SDK versions either way.
|
||||
*/
|
||||
interface SdkApiErrorLike extends Error {
|
||||
statusCode: number;
|
||||
responseBody: string;
|
||||
}
|
||||
|
||||
function isSdkApiError(error: Error): error is SdkApiErrorLike {
|
||||
const candidate = error as Partial<SdkApiErrorLike>;
|
||||
return typeof candidate.statusCode === "number" && typeof candidate.responseBody === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK embeds the raw response body in its error message; recover the
|
||||
* structured fields (message / code / request_id) when the body is JSON so
|
||||
* `mapApiError` surfaces a clean server message plus api metadata. Non-JSON
|
||||
* bodies pass through verbatim as the message.
|
||||
*/
|
||||
function parseSdkResponseBody(raw: string): ApiErrorBody {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as ApiErrorBody;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return { message: raw.trim() || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK's session polling deadline surfaces as a plain `UserError` (no
|
||||
* dedicated timeout class as of SDK 0.3.x), so it is recognized by its stable
|
||||
* message shape: "Session did not complete within the timeout (N seconds)."
|
||||
* (session-runtime's assertNotTimedOut — the SDK's only timeout UserError).
|
||||
* It is a client-side wait limit, not a usage mistake → per bl's error
|
||||
* boundary it must exit TIMEOUT, not USAGE.
|
||||
*/
|
||||
function isSdkPollingTimeout(error: UserError): boolean {
|
||||
return /did not complete within the timeout/i.test(error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an SDK-backed operation, translating SDK error types into BailianError so
|
||||
* bl's error handler produces the right exit code and hint formatting.
|
||||
* SDK `UserError` → USAGE — except the polling-deadline UserError, which is a
|
||||
* client-side timeout → TIMEOUT with a wait-longer hint; SDK `ApiError`
|
||||
* (server HTTP error) → GENERAL via `mapApiError` (server message passed
|
||||
* through verbatim, with httpStatus/apiCode/requestId metadata for
|
||||
* --output json); fetch transport failures (`TypeError: fetch failed`) are
|
||||
* rethrown untouched so the runtime error handler maps them to NETWORK with an
|
||||
* errno-specific hint, matching the native client path; any other Error →
|
||||
* GENERAL (message passed through, per bl's "don't translate server errors"
|
||||
* boundary).
|
||||
*/
|
||||
export async function withAgentErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof BailianError) throw error;
|
||||
if (error instanceof UserError) {
|
||||
if (isSdkPollingTimeout(error)) {
|
||||
throw new BailianError(
|
||||
error.message,
|
||||
ExitCode.TIMEOUT,
|
||||
// `bl` prefix is safe: agent commands ship on `bl` only.
|
||||
"The session may still be running — check `bl managed-agent session get --session-id <id>` or `session events`.",
|
||||
);
|
||||
}
|
||||
throw new BailianError(error.message, ExitCode.USAGE);
|
||||
}
|
||||
if (error instanceof Error && isSdkApiError(error)) {
|
||||
throw mapApiError(error.statusCode, parseSdkResponseBody(error.responseBody));
|
||||
}
|
||||
// DNS/TCP/TLS failures from the SDK's fetch: keep the original TypeError so
|
||||
// the runtime error handler classifies it as NETWORK (exit 6) + errno hint.
|
||||
if (error instanceof TypeError && error.message === "fetch failed") throw error;
|
||||
if (error instanceof Error) throw new BailianError(error.message, ExitCode.GENERAL);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RuntimeFeedbackEvent } from "@openagentpack/sdk";
|
||||
|
||||
/**
|
||||
* Render SDK runtime feedback to stderr, keeping stdout a clean data channel.
|
||||
* Used as the `onFeedback` sink for plan/apply so progress messages don't mix
|
||||
* with structured output.
|
||||
*/
|
||||
export function renderAgentFeedback(event: RuntimeFeedbackEvent): void {
|
||||
process.stderr.write(`${event.message}\n`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import {
|
||||
type IStateManager,
|
||||
LocalFileStateBackend,
|
||||
StateManager,
|
||||
type StateScope,
|
||||
} from "@openagentpack/sdk";
|
||||
|
||||
function createStateScope(configPath: string, projectName?: string): StateScope {
|
||||
const resolved = resolve(configPath);
|
||||
return { projectId: projectName ?? basename(dirname(resolved)) };
|
||||
}
|
||||
|
||||
/** Load or initialize a file-based StateManager (mirrors OpenAgentPack CLI). */
|
||||
export async function loadFileState(
|
||||
configPath: string,
|
||||
statePath?: string,
|
||||
projectName?: string,
|
||||
): Promise<IStateManager> {
|
||||
const resolved = resolve(configPath);
|
||||
const backend = new LocalFileStateBackend({ configPath: resolved, statePath });
|
||||
const path = backend.getStatePath(createStateScope(resolved, projectName));
|
||||
return StateManager.load(path);
|
||||
}
|
||||
@@ -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.8-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.8-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628`
|
||||
: SINGLE_MODEL[options.provider]!;
|
||||
|
||||
const toolBlock =
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,11 +22,15 @@ export function listenLocalServer(server: http.Server, port = 0): Promise<number
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
/**
|
||||
* Open a local file, directory, or URL with the OS default handler
|
||||
* (best-effort, cross-platform). Arguments are passed to `execFile` as an array
|
||||
* so the target is never interpreted by a shell.
|
||||
*/
|
||||
export function openPath(target: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
const args = platform === "win32" ? ["/c", "start", "", target] : [target];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
@@ -35,3 +39,8 @@ export function openInBrowser(url: string): Promise<void> {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
return openPath(url);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const CHAT_FLAGS = {
|
||||
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.7-max)" },
|
||||
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.8-max)" },
|
||||
message: {
|
||||
type: "array",
|
||||
valueHint: "<text>",
|
||||
@@ -123,7 +123,7 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const { system, messages } = parseMessages(flags);
|
||||
|
||||
const model = flags.model || settings.defaultTextModel || "qwen3.7-max";
|
||||
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
|
||||
const shouldStream = flags.stream || process.stdout.isTTY;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -91,6 +91,23 @@ 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";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
AGENT_COMMANDS,
|
||||
agentCommand,
|
||||
agentLaunchable,
|
||||
launchAgent,
|
||||
} from "../src/commands/config/agent-launch.ts";
|
||||
|
||||
test("agentCommand 返回已知 agent 的可执行命令,未知返回 undefined", () => {
|
||||
expect(agentCommand("qwen-code")).toBe("qwen");
|
||||
expect(agentCommand("codex")).toBe("codex");
|
||||
expect(agentCommand("nope")).toBeUndefined();
|
||||
// Guards against prototype keys leaking through the allowlist lookup.
|
||||
expect(agentCommand("toString")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("AGENT_COMMANDS 覆盖所有已知 agent id", () => {
|
||||
expect(Object.keys(AGENT_COMMANDS).sort()).toEqual(
|
||||
["claude-code", "codex", "hermes", "opencode", "openclaw", "qwen-code"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("launchAgent 对未知 id 抛错且不启动任何进程", async () => {
|
||||
await expect(launchAgent("definitely-not-an-agent")).rejects.toThrow(/Unknown agent/);
|
||||
});
|
||||
|
||||
test("agentLaunchable 对未知 id 返回 false,不探测 PATH", async () => {
|
||||
expect(await agentLaunchable("definitely-not-an-agent")).toBe(false);
|
||||
// Prototype keys must not resolve to a launchable command either.
|
||||
expect(await agentLaunchable("toString")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, sep } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { listAssets, resolveAssetPath, contentType } from "../src/commands/config/assets.ts";
|
||||
|
||||
/** Build an isolated temp output base and clean it up afterwards. */
|
||||
function withBase(fn: (base: string) => void): void {
|
||||
const base = mkdtempSync(join(tmpdir(), "bl-assets-"));
|
||||
try {
|
||||
fn(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function put(base: string, rel: string, content = "x"): string {
|
||||
const path = join(base, rel);
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
test("listAssets 按分类归类并识别类型", () => {
|
||||
withBase((base) => {
|
||||
put(base, "images/a.png");
|
||||
put(base, "videos/clip.mp4");
|
||||
put(base, "speech/voice.mp3");
|
||||
put(base, "notes.txt"); // loose file -> other
|
||||
|
||||
const { base: reported, assets } = listAssets(base);
|
||||
expect(reported).toBe(base);
|
||||
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
|
||||
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image", ext: "png" });
|
||||
expect(byName["clip.mp4"]).toMatchObject({ category: "videos", kind: "video", ext: "mp4" });
|
||||
expect(byName["voice.mp3"]).toMatchObject({ category: "speech", kind: "audio", ext: "mp3" });
|
||||
expect(byName["notes.txt"]).toMatchObject({ category: "other", kind: "other" });
|
||||
});
|
||||
});
|
||||
|
||||
test("listAssets 递归扫描任意子文件夹(非预设分类目录)", () => {
|
||||
withBase((base) => {
|
||||
put(base, "news-articles/report.md");
|
||||
put(base, "custom/deep/nested/pic.png");
|
||||
put(base, "images/a.png");
|
||||
|
||||
const { assets } = listAssets(base);
|
||||
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
|
||||
// Arbitrary top-level folder becomes the category.
|
||||
expect(byName["report.md"]).toMatchObject({
|
||||
category: "news-articles",
|
||||
kind: "other",
|
||||
ext: "md",
|
||||
});
|
||||
// Deeply nested file is discovered; category is its top-level folder.
|
||||
expect(byName["pic.png"]).toMatchObject({ category: "custom", kind: "image" });
|
||||
expect(byName["pic.png"]!.relPath).toBe(join("custom", "deep", "nested", "pic.png"));
|
||||
// Known category dirs still work.
|
||||
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image" });
|
||||
});
|
||||
});
|
||||
|
||||
test("listAssets 按生成时间倒序排列", () => {
|
||||
withBase((base) => {
|
||||
const older = put(base, "images/old.png");
|
||||
const newer = put(base, "images/new.png");
|
||||
// Force a stable ordering by stamping mtimes.
|
||||
utimesSync(older, new Date(1000), new Date(1000));
|
||||
utimesSync(newer, new Date(2000), new Date(2000));
|
||||
|
||||
const { assets } = listAssets(base);
|
||||
expect(assets.map((a) => a.name)).toEqual(["new.png", "old.png"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listAssets 目录不存在时返回空", () => {
|
||||
const { assets } = listAssets(join(tmpdir(), "bl-assets-does-not-exist-xyz"));
|
||||
expect(assets).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolveAssetPath 阻止目录穿越", () => {
|
||||
withBase((base) => {
|
||||
put(base, "images/a.png");
|
||||
expect(resolveAssetPath(base, "images/a.png")).toBe(join(base, "images/a.png"));
|
||||
expect(resolveAssetPath(base, "../../etc/passwd")).toBeNull();
|
||||
expect(resolveAssetPath(base, "")).toBeNull();
|
||||
expect(resolveAssetPath(base, "images" + sep + ".." + sep + ".." + sep + "outside")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("contentType 映射常见扩展名", () => {
|
||||
expect(contentType(".png")).toBe("image/png");
|
||||
expect(contentType(".MP4")).toBe("video/mp4");
|
||||
expect(contentType(".mp3")).toBe("audio/mpeg");
|
||||
expect(contentType(".xyz")).toBe("application/octet-stream");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { makeAuthUiBridge } from "../src/commands/auth/console-ui.ts";
|
||||
import type { AuthState, AuthStore, Identity, Settings } from "bailian-cli-core";
|
||||
|
||||
/** Build a bridge over a fake AuthStore. Only describe()/logout() are used by
|
||||
* the surface under test; startConsoleLogin() is intentionally not exercised
|
||||
* (it opens a browser and starts a real callback server). */
|
||||
function bridgeWith(state: AuthState, onLogout?: (scope: string) => Promise<boolean>) {
|
||||
const authStore = {
|
||||
describe: () => state,
|
||||
stored: () => ({ apiKey: false, console: false, openapi: false }),
|
||||
resolveBaseUrl: () => "https://dashscope.aliyuncs.com",
|
||||
login: async () => {},
|
||||
logout: onLogout ?? (async () => false),
|
||||
path: "/tmp/config.json",
|
||||
} as unknown as AuthStore;
|
||||
return makeAuthUiBridge({
|
||||
identity: {} as unknown as Identity,
|
||||
settings: {} as unknown as Settings,
|
||||
authStore,
|
||||
});
|
||||
}
|
||||
|
||||
test("status: console 凭证 -> primary=console,带 region/site 与掩码 token", () => {
|
||||
const st = bridgeWith({
|
||||
console: {
|
||||
token: "abcd1234efgh5678",
|
||||
region: "cn-beijing",
|
||||
site: "domestic",
|
||||
source: "config",
|
||||
},
|
||||
}).status();
|
||||
expect(st.authenticated).toBe(true);
|
||||
expect(st.primary).toBe("console");
|
||||
expect(st.methods).toEqual({ apiKey: false, console: true, openapi: false });
|
||||
expect(st.region).toBe("cn-beijing");
|
||||
expect(st.site).toBe("domestic");
|
||||
expect(st.masked).toContain("...");
|
||||
// Masked, never the raw token.
|
||||
expect(st.masked).not.toBe("abcd1234efgh5678");
|
||||
});
|
||||
|
||||
test("status: 无任何凭证 -> 未认证,无 masked", () => {
|
||||
const st = bridgeWith({}).status();
|
||||
expect(st.authenticated).toBe(false);
|
||||
expect(st.primary).toBe(null);
|
||||
expect(st.masked).toBeUndefined();
|
||||
expect(st.methods).toEqual({ apiKey: false, console: false, openapi: false });
|
||||
});
|
||||
|
||||
test("status: 仅 apiKey -> primary=apiKey", () => {
|
||||
const st = bridgeWith({
|
||||
apiKey: { token: "sk-1234567890", baseUrl: "https://dashscope.aliyuncs.com", source: "env" },
|
||||
}).status();
|
||||
expect(st.primary).toBe("apiKey");
|
||||
expect(st.methods.apiKey).toBe(true);
|
||||
expect(st.region).toBeUndefined();
|
||||
});
|
||||
|
||||
test("logout 委托给 authStore.logout('all')", async () => {
|
||||
let scope = "";
|
||||
const bridge = bridgeWith(
|
||||
{ apiKey: { token: "sk-x", baseUrl: "https://x", source: "config" } },
|
||||
async (s) => {
|
||||
scope = s;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
expect(await bridge.logout()).toBe(true);
|
||||
expect(scope).toBe("all");
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -41,11 +42,14 @@ function readJsonAt(...segments: string[]): Record<string, unknown> {
|
||||
|
||||
describe("config agent writers", () => {
|
||||
test("claude-code 写入 env 与 onboarding,并合并已有 env", () => {
|
||||
// 预置一个无关 env 键,验证合并保留
|
||||
// 预置一个无关 env 键与旧的 ANTHROPIC_API_KEY,验证合并保留 / 旧键清理
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({ env: { KEEP_ME: "1" }, other: true }),
|
||||
JSON.stringify({
|
||||
env: { KEEP_ME: "1", ANTHROPIC_API_KEY: "sk-stale" },
|
||||
other: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = claudeCode.write({
|
||||
@@ -61,6 +65,7 @@ describe("config agent writers", () => {
|
||||
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");
|
||||
@@ -70,26 +75,151 @@ describe("config agent writers", () => {
|
||||
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
|
||||
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");
|
||||
const security = settings.security as { auth: Record<string, string> };
|
||||
expect(security.auth.selectedType).toBe("openai");
|
||||
expect(security.auth.apiKey).toBe("sk-q");
|
||||
expect(security.auth.baseUrl).toBe(OAI_URL);
|
||||
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
|
||||
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
|
||||
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-cli",
|
||||
name: "[Bailian] qwen3-coder-plus",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "BAILIAN_CLI_API_KEY",
|
||||
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" });
|
||||
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",
|
||||
@@ -99,12 +229,108 @@ describe("config agent writers", () => {
|
||||
expect(providers.openai).toBeUndefined();
|
||||
});
|
||||
|
||||
test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
|
||||
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", () => {
|
||||
@@ -114,7 +340,11 @@ describe("config agent writers", () => {
|
||||
JSON.stringify({ provider: { other: { name: "Other" } } }),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
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();
|
||||
@@ -137,60 +367,140 @@ describe("config agent writers", () => {
|
||||
).toBe("@ai-sdk/openai-compatible");
|
||||
});
|
||||
|
||||
test("openclaw 写入 provider、api 与 primary", () => {
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
|
||||
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");
|
||||
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
|
||||
const agents = config.agents as { defaults: { model: { primary: string } } };
|
||||
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({});
|
||||
|
||||
// anthropic 端点用 anthropic-messages
|
||||
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
// --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.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
|
||||
"bailian-cli"
|
||||
].api,
|
||||
).toBe("anthropic-messages");
|
||||
(config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary,
|
||||
).toBe("bailian-cli/qwen3-max");
|
||||
});
|
||||
|
||||
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {
|
||||
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" }] }),
|
||||
yaml.stringify({
|
||||
custom_providers: [{ name: "other", base_url: "https://x" }],
|
||||
}),
|
||||
);
|
||||
|
||||
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
|
||||
hermes.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-h",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
|
||||
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
|
||||
expect(names).toContain("other");
|
||||
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry.base_url).toBe(OAI_URL);
|
||||
expect(entry.api_key).toBe("sk-h");
|
||||
expect(entry.api_mode).toBe("chat_completions");
|
||||
// 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 端点用 anthropic_messages
|
||||
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
|
||||
// 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"));
|
||||
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry2.api_mode).toBe("anthropic_messages");
|
||||
// upsert:bailian-cli 项不重复
|
||||
expect(
|
||||
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
|
||||
).toHaveLength(1);
|
||||
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(cc-switch 对齐结构,合并保留)", () => {
|
||||
test("codex 写入 config.toml 与 auth.json(官方 env_key 结构,合并保留)", () => {
|
||||
// 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并
|
||||
mkdirSync(join(home, ".codex"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -207,14 +517,20 @@ describe("config agent writers", () => {
|
||||
// 预置 auth.json 无关键,验证合并保留
|
||||
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
|
||||
|
||||
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
|
||||
const 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_reasoning_effort = "high"');
|
||||
expect(toml).toContain("disable_response_storage = true");
|
||||
expect(toml).toContain("[model_providers.bailian-cli]");
|
||||
expect(toml).toContain(`base_url = "${OAI_URL}"`);
|
||||
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
|
||||
// 未传 --wire-api 时默认 responses(新版 Codex 已不支持 chat)
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
// 合并:保留用户已有的无关配置
|
||||
@@ -224,6 +540,18 @@ describe("config agent writers", () => {
|
||||
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>", () => {
|
||||
@@ -236,4 +564,19 @@ describe("config agent writers", () => {
|
||||
);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,43 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
|
||||
expect(res.json.secretKeys).toContain("api_key");
|
||||
expect(res.json.keys).toContain("default_image_to_video_model");
|
||||
expect(res.json.keys).toContain("default_reference_to_video_model");
|
||||
// Console/telemetry fields are editable via the UI (full ConfigFile surface).
|
||||
expect(res.json.keys).toContain("console_site");
|
||||
expect(res.json.keys).toContain("telemetry");
|
||||
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
|
||||
expect(res.json.booleanKeys).toContain("telemetry");
|
||||
// Default field hints are surfaced as prefilled values in the UI.
|
||||
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-2.0");
|
||||
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
|
||||
expect(res.json.fieldDefaults.output_dir).toContain("bailian-output");
|
||||
expect(res.json.fieldDefaults.timeout).toBe("300");
|
||||
expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com");
|
||||
// Per-category model catalog (click-to-fill suggestions) is exposed too.
|
||||
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-2.0" });
|
||||
expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain(
|
||||
"happyhorse-1.1-i2v",
|
||||
);
|
||||
expect(res.json.modelCatalog.default_speech_model.map((m: { id: string }) => m.id)).toContain(
|
||||
"fun-asr",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /api/auth/status 无 bridge 时返回未认证;login/logout 返回 400", async () => {
|
||||
await withServer(async (port) => {
|
||||
// The test harness builds the server without an auth bridge, so the auth
|
||||
// endpoints degrade safely instead of throwing.
|
||||
const status = await httpJson(port, "GET", `/api/auth/status?token=${TOKEN}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.json.authenticated).toBe(false);
|
||||
expect(status.json.methods).toEqual({ apiKey: false, console: false, openapi: false });
|
||||
expect(status.json.primary).toBe(null);
|
||||
|
||||
const login = await httpJson(port, "POST", `/api/auth/login?token=${TOKEN}`);
|
||||
expect(login.status).toBe(400);
|
||||
|
||||
const logout = await httpJson(port, "POST", `/api/auth/logout?token=${TOKEN}`);
|
||||
expect(logout.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,44 +167,42 @@ test("POST /api/profile 写命名 profile(timeout 强制为 number),空串
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
|
||||
test("POST /api/profile 可编辑 console/telemetry 字段并按类型持久化", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile(
|
||||
{
|
||||
api_key: "sk-old",
|
||||
output: "json",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
},
|
||||
"stage",
|
||||
);
|
||||
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "sk-new" } },
|
||||
body: {
|
||||
name: "stage",
|
||||
data: {
|
||||
api_key: "sk-stage",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: "42",
|
||||
telemetry: "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
|
||||
const profile = readConfigFile("stage");
|
||||
expect(profile).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
api_key: "sk-stage",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
});
|
||||
expect(profile.output).toBeUndefined();
|
||||
|
||||
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
|
||||
expect(rawConfig.stage).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
// Coerced to the right JSON types, not left as strings.
|
||||
expect(rawConfig.stage.console_switch_agent).toBe(42);
|
||||
expect(rawConfig.stage.telemetry).toBe(false);
|
||||
|
||||
// Invalid enum value is rejected.
|
||||
const bad = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { console_site: "mars" } },
|
||||
});
|
||||
expect(rawConfig.stage.output).toBeUndefined();
|
||||
expect(bad.status).toBe(400);
|
||||
expect(String(bad.json.error)).toMatch(/console_site/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -217,9 +217,8 @@ describe("e2e: auth", () => {
|
||||
authorization: "Bearer sk-e2e-placeholder",
|
||||
sourceConfig: expect.any(String),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
model: "qwen3.8-max",
|
||||
stream: false,
|
||||
enable_thinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -267,7 +266,7 @@ describe("e2e: auth", () => {
|
||||
expect(config["token-plan"]).toMatchObject({
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: validationServer.baseUrl,
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
@@ -315,9 +314,8 @@ describe("e2e: auth", () => {
|
||||
authorization: "Bearer sk-sp-e2e-placeholder",
|
||||
sourceConfig: expect.any(String),
|
||||
body: {
|
||||
model: "qwen3.8-max-preview",
|
||||
model: "qwen3.8-max",
|
||||
stream: false,
|
||||
enable_thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -330,7 +328,7 @@ describe("e2e: auth", () => {
|
||||
expect(config["token-plan"]).toMatchObject({
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_text_model: "qwen3.8-max",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
|
||||
@@ -295,7 +295,9 @@ 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");
|
||||
});
|
||||
|
||||
@@ -390,7 +392,7 @@ describe("e2e: config", () => {
|
||||
expect(stderr).toMatch(/agent|--base-url|--model/i);
|
||||
});
|
||||
|
||||
test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => {
|
||||
test("config agent 缺少 --api-key/--key 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
@@ -402,7 +404,146 @@ describe("e2e: config", () => {
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--api-key|Usage:/i);
|
||||
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 () => {
|
||||
@@ -461,7 +602,7 @@ describe("e2e: config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => {
|
||||
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(
|
||||
@@ -483,7 +624,9 @@ describe("e2e: config", () => {
|
||||
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");
|
||||
@@ -492,7 +635,7 @@ describe("e2e: config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent hermes 写入 custom_providers 结构", async () => {
|
||||
test("config agent hermes 写入官方扁平 model.* 结构", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
@@ -513,9 +656,12 @@ describe("e2e: config", () => {
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
|
||||
expect(yamlText).toContain("custom_providers");
|
||||
expect(yamlText).toContain("bailian-cli");
|
||||
expect(yamlText).toContain("api_mode: chat_completions");
|
||||
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.8-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
|
||||
@@ -96,6 +96,78 @@ describe("e2e: image edit", () => {
|
||||
"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", () => {
|
||||
|
||||
@@ -61,6 +61,137 @@ describe("e2e: image generate", () => {
|
||||
expect(data.mode).toBe("sync");
|
||||
expect(data.request?.model).toBe("wan2.7-image");
|
||||
});
|
||||
|
||||
test("z-image-turbo dry-run 走 sync multimodal", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"z-image-turbo",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--size",
|
||||
"1024*1024",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ mode?: string; request?: { model?: string } }>(stdout);
|
||||
expect(data.mode).toBe("sync");
|
||||
expect(data.request?.model).toBe("z-image-turbo");
|
||||
});
|
||||
|
||||
test("qwen-image-plus dry-run 走 sync multimodal", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-plus",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--size",
|
||||
"1328*1328",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
mode?: string;
|
||||
path?: string;
|
||||
request?: { model?: string };
|
||||
}>(stdout);
|
||||
expect(data.mode).toBe("sync");
|
||||
expect(data.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
|
||||
expect(data.request?.model).toBe("qwen-image-plus");
|
||||
});
|
||||
|
||||
test("qwen-image-plus 默认 1:1 映射为 1328*1328", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"qwen-image-plus",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
request?: { parameters?: { size?: string; prompt_extend?: boolean } };
|
||||
}>(stdout);
|
||||
expect(data.request?.parameters?.size).toBe("1328*1328");
|
||||
});
|
||||
|
||||
test("z-image-turbo 默认 prompt_extend 为 false", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"z-image-turbo",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
request?: { parameters?: { size?: string; prompt_extend?: boolean } };
|
||||
}>(stdout);
|
||||
expect(data.request?.parameters?.prompt_extend).toBe(false);
|
||||
expect(data.request?.parameters?.size).toBe("1024*1024");
|
||||
});
|
||||
|
||||
test("wanx-v1 默认 1:1 映射为 1024*1024", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"wanx-v1",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
request?: { parameters?: { size?: string }; input?: { prompt?: string } };
|
||||
}>(stdout);
|
||||
expect(data.request?.parameters?.size).toBe("1024*1024");
|
||||
expect(data.request?.input?.prompt).toBe("一只猫");
|
||||
});
|
||||
|
||||
test("wanx2.0-t2i-turbo dry-run 走 text2image prompt 路径", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(IMAGE_ROUTES, [
|
||||
"image",
|
||||
"generate",
|
||||
"--model",
|
||||
"wanx2.0-t2i-turbo",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--size",
|
||||
"1024*1024",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
mode?: string;
|
||||
path?: string;
|
||||
request?: { model?: string; input?: { prompt?: string; messages?: unknown } };
|
||||
}>(stdout);
|
||||
expect(data.mode).toBe("async");
|
||||
expect(data.path).toBe("/api/v1/services/aigc/text2image/image-synthesis");
|
||||
expect(data.request?.model).toBe("wanx2.0-t2i-turbo");
|
||||
expect(data.request?.input?.prompt).toBe("一只猫");
|
||||
expect(data.request?.input?.messages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { e2eFixturesDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
/**
|
||||
* managed-agent 凭证链 e2e:验证 bl 自有配置体系(config 写入 / 命名 Profile /
|
||||
* logout)与错误映射如何流入 SDK 引擎。全部离线:凭证门禁用 `managed-agent plan`
|
||||
* 验证(空 state 不发网络请求,但 auth: "apiKey" 硬门禁 + 引擎全量 provider key
|
||||
* 断言照常生效);`validate` / `state list` 属离线命令,无凭证也必须可用。
|
||||
* 配置一律通过 BAILIAN_CONFIG_DIR 指向临时目录,绝不触碰真实用户配置。
|
||||
*/
|
||||
|
||||
const ROUTES = {
|
||||
...MANAGED_AGENT_ROUTES,
|
||||
"auth logout": "authLogout",
|
||||
};
|
||||
|
||||
const AGENTS_YAML = join(e2eFixturesDir, "managed-agent", "agents.yaml");
|
||||
const AGENTS_YAML_INVALID = join(e2eFixturesDir, "managed-agent", "agents-invalid.yaml");
|
||||
const AGENTS_YAML_MULTI = join(e2eFixturesDir, "managed-agent", "agents-multi.yaml");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** 新建隔离配置目录并写入 config.json;返回子进程 env 覆盖(清空外部凭证 env)。 */
|
||||
function makeConfigEnv(config: Record<string, unknown>): NodeJS.ProcessEnv {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-managed-agent-auth-"));
|
||||
tempDirs.push(configDir);
|
||||
writeFileSync(join(configDir, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||
return {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
BAILIAN_BASE_URL: "",
|
||||
BAILIAN_WORKSPACE_ID: "",
|
||||
};
|
||||
}
|
||||
|
||||
function validateArgs(file: string): string[] {
|
||||
return ["managed-agent", "validate", "--file", file, "--quiet"];
|
||||
}
|
||||
|
||||
/** plan 是凭证门禁命令:空 state 下不发网络,但 authStage + 引擎断言照常生效。 */
|
||||
function planArgs(file: string): string[] {
|
||||
return ["managed-agent", "plan", "--file", file, "--quiet"];
|
||||
}
|
||||
|
||||
/** 隔离宿主机的 ~/.agents/config.json,避免它强制覆盖 provider 凭证 env。 */
|
||||
function isolatedAgentsConfigEnv(): NodeJS.ProcessEnv {
|
||||
return { AGENTS_CONFIG_PATH: join(tmpdir(), "bl-e2e-no-agents-config.json") };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在临时目录里搭一套非空 state 的项目:agents.yaml 复用单 provider fixture,
|
||||
* agents.state.json 预置一条已追踪资源 —— 非空 state 是触发 plan 默认 refresh
|
||||
* 路径的前提,用于验证 --dry-run 强制离线。目录纳入 tempDirs 自动清理。
|
||||
*/
|
||||
function makeStatefulProject(): { configPath: string; statePath: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-managed-agent-dry-run-"));
|
||||
tempDirs.push(dir);
|
||||
const configPath = join(dir, "agents.yaml");
|
||||
const statePath = join(dir, "agents.state.json");
|
||||
writeFileSync(configPath, readFileSync(AGENTS_YAML, "utf8"));
|
||||
writeFileSync(
|
||||
statePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
resources: [
|
||||
{
|
||||
address: { provider: "bailian", type: "agent", name: "assistant" },
|
||||
remote_id: "agent-e2e-dry-run",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return { configPath, statePath };
|
||||
}
|
||||
|
||||
/** 分配一个刚释放的本地端口,连接必然 ECONNREFUSED,用于网络错误场景。 */
|
||||
async function closedPort(): Promise<number> {
|
||||
const server = createServer();
|
||||
try {
|
||||
await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("failed to allocate a closed port");
|
||||
}
|
||||
return address.port;
|
||||
} finally {
|
||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
||||
}
|
||||
}
|
||||
|
||||
describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错误映射)", () => {
|
||||
test("config.json 写入的 api_key 流入引擎,plan 离线通过", async () => {
|
||||
const env = makeConfigEnv({ api_key: "sk-e2e-config-write" });
|
||||
const { stderr, exitCode } = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("active_config 指向的命名 Profile 提供凭证时通过", async () => {
|
||||
const env = makeConfigEnv({
|
||||
work: { api_key: "sk-e2e-profile-work" },
|
||||
active_config: "work",
|
||||
});
|
||||
const { stderr, exitCode } = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("active_config 切到无凭证 Profile 时报统一 AUTH 错误 (3)", async () => {
|
||||
const env = makeConfigEnv({
|
||||
work: { api_key: "sk-e2e-profile-work" },
|
||||
empty: {},
|
||||
active_config: "empty",
|
||||
});
|
||||
const { stderr, exitCode } = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env);
|
||||
expect(exitCode).toBe(3);
|
||||
expect(stderr).toMatch(/auth login|API key/i);
|
||||
});
|
||||
|
||||
test("auth logout 清除凭证后 plan 报 AUTH,而非用残留凭证", async () => {
|
||||
const env = makeConfigEnv({ api_key: "sk-e2e-before-logout" });
|
||||
|
||||
const before = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env);
|
||||
expect(before.exitCode, before.stderr).toBe(0);
|
||||
|
||||
const logout = await runCommandE2e(ROUTES, ["auth", "logout"], env);
|
||||
expect(logout.exitCode, logout.stderr).toBe(0);
|
||||
|
||||
const after = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env);
|
||||
expect(after.exitCode).toBe(3);
|
||||
expect(after.stderr).toMatch(/auth login|API key/i);
|
||||
});
|
||||
|
||||
test("agents.yaml schema 错误映射为 USAGE (2),不透传原始 zod dump", async () => {
|
||||
const env = makeConfigEnv({});
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
validateArgs(AGENTS_YAML_INVALID),
|
||||
env,
|
||||
);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/agents/i);
|
||||
expect(stderr).not.toMatch(/"code":\s*"invalid_type"/);
|
||||
});
|
||||
|
||||
test("validate --output json 成功路径 stdout 为单个合法 JSON", async () => {
|
||||
const env = makeConfigEnv({ api_key: "sk-e2e-config-write" });
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
["managed-agent", "validate", "--file", AGENTS_YAML, "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ valid?: boolean; diagnostics?: unknown[] }>(stdout);
|
||||
expect(data.valid).toBe(true);
|
||||
expect(Array.isArray(data.diagnostics)).toBe(true);
|
||||
});
|
||||
|
||||
test("SDK fetch 连不上时映射为 NETWORK (6) + errno hint,不降级成 GENERAL", async () => {
|
||||
const port = await closedPort();
|
||||
const env = makeConfigEnv({
|
||||
api_key: "sk-e2e-network",
|
||||
base_url: `http://127.0.0.1:${port}`,
|
||||
});
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
["managed-agent", "session", "get", "--session-id", "sess_net", "--file", AGENTS_YAML],
|
||||
env,
|
||||
);
|
||||
expect(exitCode).toBe(6);
|
||||
expect(stderr).toMatch(/Network request failed/i);
|
||||
expect(stderr).toMatch(/ECONNREFUSED|refused/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: managed-agent 鉴权分层(离线命令免登录 / 联网命令统一 apiKey 门禁)", () => {
|
||||
test("validate 无任何凭证也离线通过 (0)", async () => {
|
||||
const env = makeConfigEnv({});
|
||||
const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("state list 无任何凭证也离线通过 (0),stdout 为合法 JSON", async () => {
|
||||
const env = makeConfigEnv({});
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
["managed-agent", "state", "list", "--file", AGENTS_YAML, "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ resources?: unknown[] }>(stdout);
|
||||
expect(Array.isArray(data.resources)).toBe(true);
|
||||
});
|
||||
|
||||
test("plan --no-refresh 无登录时仍被 apiKey 硬门禁拦住 (3)", async () => {
|
||||
const env = makeConfigEnv({});
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
[...planArgs(AGENTS_YAML), "--no-refresh"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode).toBe(3);
|
||||
expect(stderr).toMatch(/auth login|API key/i);
|
||||
});
|
||||
|
||||
test("已登录 bailian 时,多 provider 配置下 plan --no-refresh 离线通过,不查其他 provider key (0)", async () => {
|
||||
const env = {
|
||||
...makeConfigEnv({ api_key: "sk-e2e-no-refresh" }),
|
||||
...isolatedAgentsConfigEnv(),
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CLAUDE_API_KEY: "",
|
||||
};
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
[...planArgs(AGENTS_YAML_MULTI), "--no-refresh"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("统一登录门禁:只配 claude key 未登录 bailian 时,plan --provider claude 仍报 AUTH (3)", async () => {
|
||||
const env = {
|
||||
...makeConfigEnv({}),
|
||||
...isolatedAgentsConfigEnv(),
|
||||
ANTHROPIC_API_KEY: "sk-ant-e2e-scope",
|
||||
CLAUDE_API_KEY: "",
|
||||
};
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
[...planArgs(AGENTS_YAML_MULTI), "--provider", "claude"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode).toBe(3);
|
||||
expect(stderr).toMatch(/auth login|API key/i);
|
||||
});
|
||||
|
||||
test("已登录但缺 claude key 时,全量断言拦住并给 ANTHROPIC_API_KEY hint (3)", async () => {
|
||||
const env = {
|
||||
...makeConfigEnv({ api_key: "sk-e2e-bailian-present" }),
|
||||
...isolatedAgentsConfigEnv(),
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CLAUDE_API_KEY: "",
|
||||
};
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
[...planArgs(AGENTS_YAML_MULTI), "--provider", "claude"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode).toBe(3);
|
||||
expect(stderr).toMatch(/ANTHROPIC_API_KEY/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: plan --dry-run 离线契约(不联网 / 不写 state / 免凭证)", () => {
|
||||
test("无任何凭证时 plan --dry-run 不报 AUTH,离线出 plan (0)", async () => {
|
||||
const env = makeConfigEnv({});
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
ROUTES,
|
||||
[...planArgs(AGENTS_YAML), "--dry-run"],
|
||||
env,
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
});
|
||||
|
||||
test("有凭证且 state 非空时,--dry-run 跳过 refresh:不发请求、state 文件不变;同环境不加 --dry-run 则证明会联网", async () => {
|
||||
const { configPath, statePath } = makeStatefulProject();
|
||||
// base_url 指向必然 ECONNREFUSED 的本地端口:一旦 refresh 真发请求必现形。
|
||||
// refresh 对 API 错误优雅降级(不影响退出码),因此用 stderr 的
|
||||
// "Failed to refresh" 告警作为「发过请求」的观测信号。
|
||||
const port = await closedPort();
|
||||
const env = makeConfigEnv({
|
||||
api_key: "sk-e2e-dry-run",
|
||||
base_url: `http://127.0.0.1:${port}`,
|
||||
});
|
||||
const stateBefore = readFileSync(statePath, "utf8");
|
||||
|
||||
// 对照组:不加 --dry-run,默认 refresh 路径真实访问远端 → 出现 refresh 失败告警。
|
||||
const withoutDryRun = await runCommandE2e(ROUTES, planArgs(configPath), env);
|
||||
expect(withoutDryRun.stderr).toMatch(/Failed to refresh/i);
|
||||
|
||||
// --dry-run:同环境必须完全离线成功,无任何 refresh 痕迹,且不回写 state 文件。
|
||||
const withDryRun = await runCommandE2e(ROUTES, [...planArgs(configPath), "--dry-run"], env);
|
||||
expect(withDryRun.exitCode, withDryRun.stderr).toBe(0);
|
||||
expect(withDryRun.stderr).not.toMatch(/Failed to refresh/i);
|
||||
expect(readFileSync(statePath, "utf8")).toBe(stateBefore);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
/**
|
||||
* managed-agent:help / 缺参不依赖密钥;所有 mutation 命令的 --dry-run
|
||||
* 必须在构建 SDK runtime(凭证注入 / 联网 / 写盘)之前短路,因此同样不需要密钥。
|
||||
* 鉴权分层:离线命令(init/validate/state list|show|rm)auth: "none";联网命令
|
||||
* 统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。
|
||||
* 真实集成(apply/destroy/session 流程)依赖工作区内的 agents.yaml 与远端资源,
|
||||
* 属批量场景,暂仅覆盖 dry-run 契约。
|
||||
*/
|
||||
|
||||
describe("e2e: managed-agent", () => {
|
||||
test("managed-agent apply --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"apply",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--file|--provider|--yes/i);
|
||||
});
|
||||
|
||||
test("managed-agent session delete 缺少 --session-id 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"session",
|
||||
"delete",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--session-id|Missing required/i);
|
||||
});
|
||||
|
||||
test("managed-agent session send 缺少 --message 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"session",
|
||||
"send",
|
||||
"--session-id",
|
||||
"sess_e2e",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--message|Missing required/i);
|
||||
});
|
||||
|
||||
test("managed-agent skill-list --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"skill-list",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--source|--provider|--file/i);
|
||||
});
|
||||
|
||||
test("managed-agent skill-list 非法 --source 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"skill-list",
|
||||
"--source",
|
||||
"builtin",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--source must be one of: custom, official/i);
|
||||
});
|
||||
|
||||
test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => {
|
||||
// auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证,
|
||||
// 命令仍会在配置加载阶段因文件缺失短路,不产生任何网络请求。
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
MANAGED_AGENT_ROUTES,
|
||||
[
|
||||
"managed-agent",
|
||||
"skill-list",
|
||||
"--source",
|
||||
"all",
|
||||
"--file",
|
||||
"agents.e2e-missing.yaml",
|
||||
"--quiet",
|
||||
],
|
||||
{ DASHSCOPE_API_KEY: "sk-e2e-skill-list" },
|
||||
);
|
||||
// all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).not.toMatch(/--source must be one of/i);
|
||||
expect(stderr).toMatch(/File not found.*agents\.e2e-missing\.yaml/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => {
|
||||
test("init --dry-run 仅输出计划,不创建文件", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"init",
|
||||
"--dry-run",
|
||||
"--file",
|
||||
"agents.e2e-dry-run.yaml",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_create?: string; provider?: string }>(stdout);
|
||||
expect(data.would_create).toBe("agents.e2e-dry-run.yaml");
|
||||
expect(data.provider).toBe("bailian");
|
||||
});
|
||||
|
||||
test("apply --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"apply",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_apply?: { provider?: string } }>(stdout);
|
||||
expect(data.would_apply?.provider).toBe("all");
|
||||
});
|
||||
|
||||
test("destroy --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"destroy",
|
||||
"--dry-run",
|
||||
"--cascade",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_destroy?: { cascade?: boolean } }>(stdout);
|
||||
expect(data.would_destroy?.cascade).toBe(true);
|
||||
});
|
||||
|
||||
test("session create --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"session",
|
||||
"create",
|
||||
"--dry-run",
|
||||
"--agent",
|
||||
"assistant",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_create_session?: { agent?: string } }>(stdout);
|
||||
expect(data.would_create_session?.agent).toBe("assistant");
|
||||
});
|
||||
|
||||
test("session delete --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"session",
|
||||
"delete",
|
||||
"--dry-run",
|
||||
"--session-id",
|
||||
"sess_e2e",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_delete_session?: string }>(stdout);
|
||||
expect(data.would_delete_session).toBe("sess_e2e");
|
||||
});
|
||||
|
||||
test("session send --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"session",
|
||||
"send",
|
||||
"--dry-run",
|
||||
"--session-id",
|
||||
"sess_e2e",
|
||||
"--message",
|
||||
"干跑",
|
||||
"--no-stream",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_send?: { session_id?: string; message?: string; mode?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_send?.session_id).toBe("sess_e2e");
|
||||
expect(data.would_send?.message).toBe("干跑");
|
||||
expect(data.would_send?.mode).toBe("polling");
|
||||
});
|
||||
|
||||
test("session run --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"session",
|
||||
"run",
|
||||
"--dry-run",
|
||||
"--prompt",
|
||||
"干跑",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_run?: { prompt?: string; mode?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_run?.prompt).toBe("干跑");
|
||||
expect(data.would_run?.mode).toBe("streaming");
|
||||
});
|
||||
|
||||
test("state rm --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"state",
|
||||
"rm",
|
||||
"--dry-run",
|
||||
"--address",
|
||||
"bailian.agent.assistant",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_remove?: string }>(stdout);
|
||||
expect(data.would_remove).toBe("bailian.agent.assistant");
|
||||
});
|
||||
|
||||
test("state import --dry-run 仅输出计划", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
|
||||
"managed-agent",
|
||||
"state",
|
||||
"import",
|
||||
"--dry-run",
|
||||
"--address",
|
||||
"bailian.agent.assistant",
|
||||
"--remote-id",
|
||||
"agent-e2e",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_import?: string; remote_id?: string }>(stdout);
|
||||
expect(data.would_import).toBe("bailian.agent.assistant");
|
||||
expect(data.remote_id).toBe("agent-e2e");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
"text",
|
||||
"chat",
|
||||
"--model",
|
||||
"qwen3.7-max",
|
||||
"qwen3.8-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--message|Usage:/i);
|
||||
@@ -32,7 +32,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
"chat",
|
||||
"--dry-run",
|
||||
"--model",
|
||||
"qwen3.7-max",
|
||||
"qwen3.8-max",
|
||||
"--message",
|
||||
"干跑",
|
||||
"--max-tokens",
|
||||
@@ -44,16 +44,16 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => {
|
||||
const data = parseStdoutJson<{
|
||||
request?: { model?: string; messages?: Array<{ content?: string }> };
|
||||
}>(stdout);
|
||||
expect(data.request?.model).toBe("qwen3.7-max");
|
||||
expect(data.request?.model).toBe("qwen3.8-max");
|
||||
expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true);
|
||||
});
|
||||
|
||||
test("【qwen3.7-max】文本对话", async () => {
|
||||
test("【qwen3.8-max】文本对话", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [
|
||||
"text",
|
||||
"chat",
|
||||
"--model",
|
||||
"qwen3.7-max",
|
||||
"qwen3.8-max",
|
||||
"--message",
|
||||
"只回复一个字:好",
|
||||
"--max-tokens",
|
||||
|
||||
@@ -59,7 +59,9 @@ export const VIDEO_ROUTES: E2eRouteExports = {
|
||||
"video download": "videoDownload",
|
||||
};
|
||||
|
||||
export const VISION_ROUTES: E2eRouteExports = { "vision describe": "visionDescribe" };
|
||||
export const VISION_ROUTES: E2eRouteExports = {
|
||||
"vision describe": "visionDescribe",
|
||||
};
|
||||
|
||||
export const SPEECH_ROUTES: E2eRouteExports = {
|
||||
"speech synthesize": "speechSynthesize",
|
||||
@@ -84,9 +86,13 @@ export const OMNI_ROUTES: E2eRouteExports = {
|
||||
"speech synthesize": "speechSynthesize",
|
||||
};
|
||||
|
||||
export const FILE_UPLOAD_ROUTES: E2eRouteExports = { "file upload": "fileUpload" };
|
||||
export const FILE_UPLOAD_ROUTES: E2eRouteExports = {
|
||||
"file upload": "fileUpload",
|
||||
};
|
||||
|
||||
export const ADVISOR_ROUTES: E2eRouteExports = { "advisor recommend": "advisorRecommend" };
|
||||
export const ADVISOR_ROUTES: E2eRouteExports = {
|
||||
"advisor recommend": "advisorRecommend",
|
||||
};
|
||||
|
||||
export const QUOTA_ROUTES: E2eRouteExports = {
|
||||
"quota list": "quotaList",
|
||||
@@ -149,3 +155,20 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
|
||||
"token-plan assign-seats": "tokenPlanAssignSeats",
|
||||
"token-plan add-member": "tokenPlanAddMember",
|
||||
};
|
||||
|
||||
export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
|
||||
"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 rm": "managedAgentStateRm",
|
||||
"managed-agent state import": "managedAgentStateImport",
|
||||
"managed-agent session create": "managedAgentSessionCreate",
|
||||
"managed-agent session get": "managedAgentSessionGet",
|
||||
"managed-agent session delete": "managedAgentSessionDelete",
|
||||
"managed-agent session run": "managedAgentSessionRun",
|
||||
"managed-agent session send": "managedAgentSessionSend",
|
||||
"managed-agent skill-list": "managedAgentSkillList",
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("e2e: vision describe", () => {
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_text_model: "qwen3.8-max",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -40,6 +40,6 @@ describe("e2e: vision describe", () => {
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: { model?: string } }>(stdout);
|
||||
expect(data.request?.model).toBe("qwen3.8-max-preview");
|
||||
expect(data.request?.model).toBe("qwen3.8-max");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
|
||||
/**
|
||||
* 发布契约:最低 Node 版本。
|
||||
* 1) bl 全部发布包的 engines.node 必须一致(版本 bump 一动多动的另一面)。
|
||||
* 2) 外部运行时依赖 @openagentpack/sdk 的 engines 下限不得高于 bl 的下限,
|
||||
* 否则 Node 18/20 用户安装 bailian-cli 会触发 EBADENGINE / engine-strict 失败。
|
||||
* 历史上出现过冲突版本,用版本号白名单做棘轮:一旦升级依赖版本,本检查自动强制生效。
|
||||
*/
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "..", "..", "..");
|
||||
const BL_PACKAGES = ["core", "runtime", "commands", "cli", "kscli"] as const;
|
||||
|
||||
/** 上游 engines 冲突版本白名单;当前依赖版本已对齐,请勿把新版本加进来。 */
|
||||
const KNOWN_SDK_ENGINE_CONFLICT_VERSIONS = new Set<string>([]);
|
||||
|
||||
interface PackageManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
engines?: { node?: string };
|
||||
dependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
|
||||
}
|
||||
|
||||
/** 解析 ">=X.Y.Z" / ">=X" 形式的 engines 下限为可比较的 [major, minor, patch]。 */
|
||||
function parseEngineFloor(range: string): [number, number, number] {
|
||||
const matched = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
|
||||
if (!matched) throw new Error(`Unsupported engines range: ${range}`);
|
||||
return [Number(matched[1]), Number(matched[2] ?? 0), Number(matched[3] ?? 0)];
|
||||
}
|
||||
|
||||
function floorLessOrEqual(
|
||||
left: [number, number, number],
|
||||
right: [number, number, number],
|
||||
): boolean {
|
||||
for (let index = 0; index < 3; index++) {
|
||||
if (left[index]! !== right[index]!) return left[index]! < right[index]!;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
test("bl 全部发布包 engines.node 一致", () => {
|
||||
const floors = BL_PACKAGES.map((pkg) => {
|
||||
const manifest = readManifest(join(repoRoot, "packages", pkg, "package.json"));
|
||||
return { name: manifest.name, node: manifest.engines?.node };
|
||||
});
|
||||
const [first, ...rest] = floors;
|
||||
expect(first?.node).toMatch(/^>=\d+\.\d+\.\d+$/);
|
||||
for (const entry of rest) {
|
||||
expect(entry.node, `${entry.name} engines.node 与 ${first?.name} 不一致`).toBe(first?.node);
|
||||
}
|
||||
});
|
||||
|
||||
test("@openagentpack/sdk engines 下限不高于 bl 的最低 Node 版本", () => {
|
||||
const commandsManifest = readManifest(join(repoRoot, "packages", "commands", "package.json"));
|
||||
const blFloor = parseEngineFloor(commandsManifest.engines?.node ?? "");
|
||||
|
||||
const sdkManifest = readManifest(
|
||||
join(repoRoot, "packages", "commands", "node_modules", "@openagentpack", "sdk", "package.json"),
|
||||
);
|
||||
const sdkRange = sdkManifest.engines?.node;
|
||||
if (!sdkRange) return; // 无 engines 声明即不设限,兼容
|
||||
|
||||
if (KNOWN_SDK_ENGINE_CONFLICT_VERSIONS.has(sdkManifest.version)) return;
|
||||
|
||||
const sdkFloor = parseEngineFloor(sdkRange);
|
||||
expect(
|
||||
floorLessOrEqual(sdkFloor, blFloor),
|
||||
`@openagentpack/sdk@${sdkManifest.version} 要求 Node ${sdkRange},高于 bl 承诺的 ${commandsManifest.engines?.node};` +
|
||||
"这会让 Node 18/20 用户安装 bailian-cli 失败(EBADENGINE / engine-strict)。",
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
listSkills,
|
||||
listMcpServers,
|
||||
listAgents,
|
||||
getSkillDetail,
|
||||
} from "../src/commands/config/inventory.ts";
|
||||
|
||||
/** Build an isolated fake $HOME and clean it up afterwards. */
|
||||
function withHome(fn: (home: string) => void): void {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-inv-"));
|
||||
try {
|
||||
fn(home);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function write(home: string, rel: string, content: string): void {
|
||||
const path = join(home, rel);
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
test("getSkillDetail 返回 SKILL.md 原文,未知 id 返回 null", () => {
|
||||
withHome((home) => {
|
||||
write(
|
||||
home,
|
||||
".agents/skills/demo/SKILL.md",
|
||||
"---\nname: demo-skill\ndescription: A demo skill.\n---\n# Body\nhello world\n",
|
||||
);
|
||||
|
||||
const detail = getSkillDetail("demo", home);
|
||||
expect(detail).not.toBeNull();
|
||||
expect(detail?.name).toBe("demo-skill");
|
||||
expect(detail?.content).toContain("hello world");
|
||||
expect(getSkillDetail("nope", home)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 解析 SKILL.md frontmatter 与文件数", () => {
|
||||
withHome((home) => {
|
||||
write(
|
||||
home,
|
||||
".agents/skills/demo/SKILL.md",
|
||||
'---\nname: demo-skill\nmetadata:\n version: "2.1.0"\ndescription: A demo skill.\n---\n# Body\n',
|
||||
);
|
||||
write(home, ".agents/skills/demo/assets/a.md", "x");
|
||||
// Directory without SKILL.md is ignored.
|
||||
mkdirSync(join(home, ".agents/skills/not-a-skill"), { recursive: true });
|
||||
|
||||
const skills = listSkills(home);
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0]).toMatchObject({
|
||||
id: "demo",
|
||||
name: "demo-skill",
|
||||
version: "2.1.0",
|
||||
description: "A demo skill.",
|
||||
sources: ["global"],
|
||||
origin: "local",
|
||||
});
|
||||
expect(skills[0].fileCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 将 agent 目录下的软链接视为安装来源", () => {
|
||||
withHome((home) => {
|
||||
write(home, ".agents/skills/bailian-cli/SKILL.md", "---\nname: bailian-cli\n---\n");
|
||||
// Mimic `skills add`: the agent copy is a symlink back to the global dir.
|
||||
mkdirSync(join(home, ".claude/skills"), { recursive: true });
|
||||
symlinkSync(join(home, ".agents/skills/bailian-cli"), join(home, ".claude/skills/bailian-cli"));
|
||||
|
||||
const skills = listSkills(home);
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].sources).toEqual(["global", "claude-code"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 跨 agent 模块聚合并记录来源", () => {
|
||||
withHome((home) => {
|
||||
// Same skill installed in the global dir and two agent modules.
|
||||
const skillMd = "---\nname: bailian-cli\n---\n# B\n";
|
||||
write(home, ".agents/skills/bailian-cli/SKILL.md", skillMd);
|
||||
write(home, ".claude/skills/bailian-cli/SKILL.md", skillMd);
|
||||
write(home, ".qwen/skills/bailian-cli/SKILL.md", skillMd);
|
||||
// A skill only present in qwen.
|
||||
write(home, ".qwen/skills/spark-video/SKILL.md", "---\nname: spark-video\n---\n");
|
||||
|
||||
const skills = listSkills(home);
|
||||
const byId = Object.fromEntries(skills.map((s) => [s.id, s]));
|
||||
expect(byId["bailian-cli"].sources).toEqual(["global", "claude-code", "qwen-code"]);
|
||||
expect(byId["spark-video"].sources).toEqual(["qwen-code"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listSkills 目录缺失时返回空数组", () => {
|
||||
withHome((home) => {
|
||||
expect(listSkills(home)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listMcpServers 汇总 codex(toml) 与 claude(json) 的 MCP 定义", () => {
|
||||
withHome((home) => {
|
||||
write(home, ".codex/config.toml", '[mcp_servers.repl]\ncommand = "node"\nargs = ["repl.js"]\n');
|
||||
write(
|
||||
home,
|
||||
".claude.json",
|
||||
JSON.stringify({
|
||||
mcpServers: { web: { url: "https://example.com/mcp", type: "sse" } },
|
||||
projects: {
|
||||
"/proj": {
|
||||
mcpServers: { local: { command: "python", args: ["s.py"] } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const servers = listMcpServers(home);
|
||||
const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
|
||||
expect(byName.repl).toMatchObject({
|
||||
source: "codex",
|
||||
transport: "stdio",
|
||||
origin: "local",
|
||||
});
|
||||
expect(byName.repl.detail).toContain("node repl.js");
|
||||
expect(byName.web).toMatchObject({
|
||||
source: "claude-code",
|
||||
transport: "sse",
|
||||
scope: "global",
|
||||
});
|
||||
expect(byName.local).toMatchObject({
|
||||
source: "claude-code",
|
||||
transport: "stdio",
|
||||
scope: "/proj",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("listMcpServers 无配置时返回空数组", () => {
|
||||
withHome((home) => {
|
||||
expect(listMcpServers(home)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("listAgents 报告安装与已连接 bailian-cli 的状态", () => {
|
||||
withHome((home) => {
|
||||
// Claude Code: installed + configured (base url present).
|
||||
write(
|
||||
home,
|
||||
".claude/settings.json",
|
||||
JSON.stringify({
|
||||
env: { ANTHROPIC_BASE_URL: "https://x", ANTHROPIC_MODEL: "qwen3-max" },
|
||||
}),
|
||||
);
|
||||
// Codex: installed but NOT configured (no bailian-cli provider).
|
||||
write(home, ".codex/config.toml", 'model = "gpt-5"\n');
|
||||
// Qwen Code: configured via the new "[Bailian] <model>" display name.
|
||||
write(
|
||||
home,
|
||||
".qwen/settings.json",
|
||||
JSON.stringify({
|
||||
modelProviders: {
|
||||
openai: [{ id: "qwen3-coder-plus", name: "[Bailian] qwen3-coder-plus" }],
|
||||
},
|
||||
model: { name: "qwen3-coder-plus" },
|
||||
}),
|
||||
);
|
||||
// Hermes: configured via the official flat model block (no custom_providers).
|
||||
write(
|
||||
home,
|
||||
".hermes/config.yaml",
|
||||
[
|
||||
"model:",
|
||||
" default: qwen3-max",
|
||||
" provider: custom",
|
||||
" base_url: https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
" api_key: sk-test",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const agents = listAgents(home);
|
||||
const byId = Object.fromEntries(agents.map((a) => [a.id, a]));
|
||||
|
||||
expect(byId["claude-code"]).toMatchObject({
|
||||
installed: true,
|
||||
configured: true,
|
||||
model: "qwen3-max",
|
||||
origin: "local",
|
||||
});
|
||||
expect(byId.codex).toMatchObject({
|
||||
installed: true,
|
||||
configured: false,
|
||||
model: "gpt-5",
|
||||
});
|
||||
expect(byId["qwen-code"]).toMatchObject({
|
||||
installed: true,
|
||||
configured: true,
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
expect(byId.hermes).toMatchObject({
|
||||
installed: true,
|
||||
configured: true,
|
||||
model: "qwen3-max",
|
||||
});
|
||||
expect(byId.opencode).toMatchObject({
|
||||
installed: false,
|
||||
configured: false,
|
||||
});
|
||||
// Always reports all six known frameworks.
|
||||
expect(agents).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { UserError } from "@openagentpack/sdk";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { withAgentErrors } from "../src/commands/managed-agent/_engine/errors.ts";
|
||||
|
||||
/**
|
||||
* Structural stand-in for the SDK's internal `ApiError` (not exported by the
|
||||
* installed SDK version): withAgentErrors matches on statusCode/responseBody
|
||||
* fields, so any Error carrying them must map through mapApiError.
|
||||
*/
|
||||
class FakeSdkApiError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly responseBody: string,
|
||||
) {
|
||||
super(`Bailian API ${statusCode}: ${responseBody}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function catchMapped(error: unknown): Promise<BailianError> {
|
||||
try {
|
||||
await withAgentErrors(() => Promise.reject(error));
|
||||
} catch (mapped) {
|
||||
expect(mapped).toBeInstanceOf(BailianError);
|
||||
return mapped as BailianError;
|
||||
}
|
||||
throw new Error("expected withAgentErrors to throw");
|
||||
}
|
||||
|
||||
test("BailianError passes through untouched", async () => {
|
||||
const original = new BailianError("already mapped", ExitCode.AUTH);
|
||||
const mapped = await catchMapped(original);
|
||||
expect(mapped).toBe(original);
|
||||
});
|
||||
|
||||
test("SDK UserError maps to USAGE", async () => {
|
||||
const mapped = await catchMapped(new UserError("bad agents.yaml"));
|
||||
expect(mapped.exitCode).toBe(ExitCode.USAGE);
|
||||
expect(mapped.message).toBe("bad agents.yaml");
|
||||
});
|
||||
|
||||
test("SDK polling-timeout UserError maps to TIMEOUT (5), not USAGE", async () => {
|
||||
// 消息形状来自 SDK session-runtime 的 assertNotTimedOut —— 客户端等待超时,
|
||||
// 按 bl 错误边界必须归 TIMEOUT,不能告诉自动化调用方“参数错误”。
|
||||
const mapped = await catchMapped(
|
||||
new UserError("Session did not complete within the timeout (600 seconds)."),
|
||||
);
|
||||
expect(mapped.exitCode).toBe(ExitCode.TIMEOUT);
|
||||
expect(mapped.message).toBe("Session did not complete within the timeout (600 seconds).");
|
||||
expect(mapped.hint).toMatch(/session get/);
|
||||
});
|
||||
|
||||
test("提及 timeout 但非轮询超时句式的 UserError 仍归 USAGE", async () => {
|
||||
const mapped = await catchMapped(new UserError("Invalid timeout value in agents.yaml"));
|
||||
expect(mapped.exitCode).toBe(ExitCode.USAGE);
|
||||
});
|
||||
|
||||
test("SDK ApiError with DashScope-style JSON body surfaces clean message and api metadata", async () => {
|
||||
const body = JSON.stringify({
|
||||
code: "InvalidParameter",
|
||||
message: "agent name already exists",
|
||||
request_id: "req-123",
|
||||
});
|
||||
const mapped = await catchMapped(new FakeSdkApiError(400, body));
|
||||
expect(mapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(mapped.message).toBe("agent name already exists");
|
||||
expect(mapped.api).toEqual({
|
||||
httpStatus: 400,
|
||||
apiCode: "InvalidParameter",
|
||||
requestId: "req-123",
|
||||
});
|
||||
});
|
||||
|
||||
test("SDK ApiError with OpenAI-style error envelope extracts message and type", async () => {
|
||||
const body = JSON.stringify({
|
||||
error: { message: "model does not exist", type: "invalid_request_error" },
|
||||
request_id: "req-456",
|
||||
});
|
||||
const mapped = await catchMapped(new FakeSdkApiError(404, body));
|
||||
expect(mapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(mapped.message).toBe("model does not exist");
|
||||
expect(mapped.api?.apiCode).toBe("invalid_request_error");
|
||||
expect(mapped.api?.requestId).toBe("req-456");
|
||||
});
|
||||
|
||||
test("SDK ApiError with non-JSON body passes raw text through as message", async () => {
|
||||
const mapped = await catchMapped(new FakeSdkApiError(502, "Bad Gateway"));
|
||||
expect(mapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(mapped.message).toBe("Bad Gateway");
|
||||
expect(mapped.api?.httpStatus).toBe(502);
|
||||
});
|
||||
|
||||
test("SDK ApiError with empty body falls back to HTTP status message", async () => {
|
||||
const mapped = await catchMapped(new FakeSdkApiError(503, ""));
|
||||
expect(mapped.message).toBe("HTTP 503");
|
||||
expect(mapped.api?.httpStatus).toBe(503);
|
||||
});
|
||||
|
||||
test("plain Error maps to GENERAL with message passed through", async () => {
|
||||
const mapped = await catchMapped(new Error("boom"));
|
||||
expect(mapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(mapped.message).toBe("boom");
|
||||
expect(mapped.api).toBeUndefined();
|
||||
});
|
||||
|
||||
test("fetch transport TypeError is rethrown untouched for the runtime NETWORK mapping", async () => {
|
||||
const transportError = new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1"), {
|
||||
code: "ECONNREFUSED",
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await withAgentErrors(() => Promise.reject(transportError));
|
||||
throw new Error("expected withAgentErrors to throw");
|
||||
} catch (error) {
|
||||
expect(error).toBe(transportError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import { mcpMarketplaceDetailPage } from "bailian-cli-runtime";
|
||||
import {
|
||||
isMcpNotActivated,
|
||||
mcpActivateHint,
|
||||
rethrowWithMcpActivateHint,
|
||||
} from "../src/commands/mcp/activate-hint.ts";
|
||||
|
||||
describe("mcp-activate-hint", () => {
|
||||
test("识别 404 + 未开通 / MCP不存在 / MCP_IS_INVALID", () => {
|
||||
expect(
|
||||
isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found - MCP不存在或未开通")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMcpNotActivated(new BailianError("MCP request failed: 404 - MCP不存在或未开通")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found - MCP_IS_INVALID")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("裸 404 或非 MCP 错误不加开通判定", () => {
|
||||
expect(isMcpNotActivated(new BailianError("MCP request failed: 404 Not Found"))).toBe(false);
|
||||
expect(isMcpNotActivated(new BailianError("MCP request failed: 405 Method Not Allowed"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
|
||||
});
|
||||
|
||||
test("hint 含对应 server 的 MCP 广场深链", () => {
|
||||
const serverCode = "market-cmapi00073529";
|
||||
expect(mcpActivateHint(serverCode)).toContain(mcpMarketplaceDetailPage(serverCode));
|
||||
expect(mcpActivateHint(serverCode)).toMatch(/Activate|re-activate/i);
|
||||
});
|
||||
|
||||
test("WebSearch hint 含 SSE 升级说明", () => {
|
||||
expect(mcpActivateHint("WebSearch")).toMatch(/SSE|Streamable HTTP/i);
|
||||
});
|
||||
|
||||
test("rethrow 保留原 message,补 hint", () => {
|
||||
const serverCode = "market-cmapi00073529";
|
||||
const original = new BailianError(
|
||||
"MCP request failed: 404 Not Found - MCP不存在或未开通",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
try {
|
||||
rethrowWithMcpActivateHint(original, serverCode);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BailianError);
|
||||
const wrapped = error as BailianError;
|
||||
expect(wrapped.message).toBe(original.message);
|
||||
expect(wrapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(wrapped.hint).toContain(mcpMarketplaceDetailPage(serverCode));
|
||||
expect(wrapped.cause).toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
test("已有 hint 或非未开通错误原样抛出", () => {
|
||||
const withHint = new BailianError(
|
||||
"MCP request failed: 404 Not Found - MCP不存在或未开通",
|
||||
ExitCode.GENERAL,
|
||||
"already hinted",
|
||||
);
|
||||
try {
|
||||
rethrowWithMcpActivateHint(withHint, "WebSearch");
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBe(withHint);
|
||||
}
|
||||
|
||||
const other = new BailianError("MCP request failed: 401 Unauthorized");
|
||||
try {
|
||||
rethrowWithMcpActivateHint(other, "WebSearch");
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBe(other);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { rsGeneratorExp, rsEncode, qrMatrix, qrSvg } from "../src/commands/config/qr.ts";
|
||||
|
||||
test("rsGeneratorExp 匹配已知 QR 生成多项式(alpha 指数)", () => {
|
||||
// Well-documented QR generator polynomials — a strong correctness anchor for
|
||||
// the GF(256) arithmetic and polynomial construction.
|
||||
expect(rsGeneratorExp(7)).toEqual([0, 87, 229, 146, 149, 238, 102, 21]);
|
||||
expect(rsGeneratorExp(10)).toEqual([0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45]);
|
||||
expect(rsGeneratorExp(15)).toEqual([
|
||||
0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105,
|
||||
]);
|
||||
});
|
||||
|
||||
test("rsEncode 产出请求数量的纠错码字", () => {
|
||||
const ec = rsEncode([0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26], 10);
|
||||
expect(ec).toHaveLength(10);
|
||||
expect(ec.every((b) => b >= 0 && b <= 255)).toBe(true);
|
||||
});
|
||||
|
||||
test("qrMatrix 尺寸随版本增长且含三个定位图案", () => {
|
||||
const m = qrMatrix("http://127.0.0.1:8787/?token=" + "a".repeat(32));
|
||||
// ~61 byte URL -> version 4 (33x33).
|
||||
expect(m.length).toBe(33);
|
||||
expect(m[0]).toHaveLength(33);
|
||||
const size = m.length;
|
||||
// Finder pattern centers are dark (3x3 core) at the three corners.
|
||||
expect(m[3][3]).toBe(true);
|
||||
expect(m[3][size - 4]).toBe(true);
|
||||
expect(m[size - 4][3]).toBe(true);
|
||||
// Timing pattern alternates on row/col 6.
|
||||
expect(m[6][8]).toBe(true);
|
||||
expect(m[6][9]).toBe(false);
|
||||
});
|
||||
|
||||
test("qrMatrix 短文本用最小版本(V1=21x21)", () => {
|
||||
expect(qrMatrix("hi").length).toBe(21);
|
||||
});
|
||||
|
||||
test("qrSvg 返回带 viewBox 的 SVG 且含模块矩形", () => {
|
||||
const svg = qrSvg("http://127.0.0.1:8787/");
|
||||
expect(svg.startsWith("<svg")).toBe(true);
|
||||
expect(svg).toContain("viewBox=");
|
||||
expect(svg).toContain("<rect");
|
||||
});
|
||||
|
||||
test("qrMatrix 超长数据抛出清晰错误", () => {
|
||||
expect(() => qrMatrix("x".repeat(200))).toThrow(/too large/);
|
||||
});
|
||||
|
||||
// --- End-to-end: decode the produced matrix with the STANDARD reading order
|
||||
// and confirm it round-trips back to the original text. This is the real proof
|
||||
// that the code is scannable (format-info placement + mask + data placement),
|
||||
// which cannot be checked by structure alone. The format info is read from the
|
||||
// FIRST copy (top-left) using the canonical mapping, independent of the encoder.
|
||||
const MASK_FNS: Array<(r: number, c: number) => boolean> = [
|
||||
(r, c) => (r + c) % 2 === 0,
|
||||
(r) => r % 2 === 0,
|
||||
(_r, c) => c % 3 === 0,
|
||||
(r, c) => (r + c) % 3 === 0,
|
||||
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
||||
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
||||
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
||||
];
|
||||
|
||||
function qrDecode(m: boolean[][]): { text: string; mask: number; ecLevel: number } {
|
||||
const size = m.length;
|
||||
const version = (size - 17) / 4;
|
||||
const bAt = (r: number, c: number) => (m[r][c] ? 1 : 0);
|
||||
|
||||
// Read the 15-bit format info from the first copy (canonical cell mapping).
|
||||
const cells: Array<[number, number]> = [
|
||||
[0, 8],
|
||||
[1, 8],
|
||||
[2, 8],
|
||||
[3, 8],
|
||||
[4, 8],
|
||||
[5, 8], // bits 0–5
|
||||
[7, 8], // bit 6
|
||||
[8, 8], // bit 7
|
||||
[8, 7], // bit 8
|
||||
[8, 5],
|
||||
[8, 4],
|
||||
[8, 3],
|
||||
[8, 2],
|
||||
[8, 1],
|
||||
[8, 0], // bits 9–14
|
||||
];
|
||||
let fmt = 0;
|
||||
for (let i = 0; i < 15; i++) fmt |= bAt(cells[i][0], cells[i][1]) << i;
|
||||
fmt ^= 0x5412;
|
||||
const mask = (fmt >> 10) & 7;
|
||||
const ecLevel = (fmt >> 13) & 3;
|
||||
const cond = MASK_FNS[mask];
|
||||
|
||||
// Independent function/reserved-module map.
|
||||
const isFn = (r: number, c: number): boolean => {
|
||||
if (r <= 7 && c <= 7) return true; // top-left finder + separator
|
||||
if (r <= 7 && c >= size - 8) return true; // top-right finder
|
||||
if (r >= size - 8 && c <= 7) return true; // bottom-left finder
|
||||
if (r === 6 || c === 6) return true; // timing
|
||||
if (r === 8 && (c <= 8 || c >= size - 8)) return true; // format (horizontal)
|
||||
if (c === 8 && (r <= 8 || r >= size - 8)) return true; // format (vertical) + dark
|
||||
if (version >= 2) {
|
||||
const ac = size - 7;
|
||||
if (Math.abs(r - ac) <= 2 && Math.abs(c - ac) <= 2) return true; // alignment
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Read data modules in the standard right-to-left zigzag, un-masking as we go.
|
||||
const bits: number[] = [];
|
||||
let upward = true;
|
||||
for (let col = size - 1; col >= 1; col -= 2) {
|
||||
if (col === 6) col = 5;
|
||||
for (let i = 0; i < size; i++) {
|
||||
const row = upward ? size - 1 - i : i;
|
||||
for (const off of [0, 1]) {
|
||||
const cc = col - off;
|
||||
if (isFn(row, cc)) continue;
|
||||
let b = bAt(row, cc);
|
||||
if (cond(row, cc)) b ^= 1;
|
||||
bits.push(b);
|
||||
}
|
||||
}
|
||||
upward = !upward;
|
||||
}
|
||||
|
||||
let p = 0;
|
||||
const read = (n: number) => {
|
||||
let v = 0;
|
||||
for (let k = 0; k < n; k++) v = (v << 1) | (bits[p++] ?? 0);
|
||||
return v;
|
||||
};
|
||||
const mode = read(4);
|
||||
if (mode !== 0b0100) throw new Error("decode: not byte mode, got " + mode);
|
||||
const len = read(8);
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < len; i++) out.push(read(8));
|
||||
return { text: new TextDecoder().decode(new Uint8Array(out)), mask, ecLevel };
|
||||
}
|
||||
|
||||
test("qrMatrix → 标准解码能无损还原原文(失败则说明无法扫描)", () => {
|
||||
const samples = [
|
||||
"hi",
|
||||
"http://127.0.0.1:8787/",
|
||||
"http://127.0.0.1:8787/?token=" + "a".repeat(32),
|
||||
"http://127.0.0.1:65535/?token=0123456789abcdef0123456789abcdef",
|
||||
];
|
||||
for (const text of samples) {
|
||||
const decoded = qrDecode(qrMatrix(text));
|
||||
expect(decoded.text).toBe(text);
|
||||
expect(decoded.ecLevel).toBe(0b01); // error-correction level L
|
||||
expect(decoded.mask).toBeGreaterThanOrEqual(0);
|
||||
expect(decoded.mask).toBeLessThanOrEqual(7);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import { MCP_WEBSEARCH_PAGE } from "bailian-cli-runtime";
|
||||
import {
|
||||
isWebSearchMcpNotActivated,
|
||||
rethrowWithWebSearchActivateHint,
|
||||
webSearchActivateHint,
|
||||
} from "../src/commands/search/web-activate-hint.ts";
|
||||
|
||||
describe("web-activate-hint", () => {
|
||||
test("识别 404 + 未开通 / MCP不存在 / MCP_IS_INVALID", () => {
|
||||
expect(
|
||||
isWebSearchMcpNotActivated(
|
||||
new BailianError("MCP request failed: 404 Not Found - MCP不存在或未开通"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWebSearchMcpNotActivated(new BailianError("MCP request failed: 404 - MCP不存在或未开通")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWebSearchMcpNotActivated(
|
||||
new BailianError("MCP request failed: 404 Not Found - MCP_IS_INVALID"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("裸 404 或非 MCP 错误不加开通判定", () => {
|
||||
expect(isWebSearchMcpNotActivated(new BailianError("MCP request failed: 404 Not Found"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isWebSearchMcpNotActivated(new BailianError("MCP request failed: 405 Method Not Allowed")),
|
||||
).toBe(false);
|
||||
expect(isWebSearchMcpNotActivated(new Error("MCP不存在或未开通"))).toBe(false);
|
||||
});
|
||||
|
||||
test("hint 含 MCP 广场 WebSearch 深链", () => {
|
||||
expect(webSearchActivateHint()).toContain(MCP_WEBSEARCH_PAGE);
|
||||
expect(webSearchActivateHint()).toMatch(/Activate|re-activate/i);
|
||||
});
|
||||
|
||||
test("rethrow 保留原 message,补 Hint", () => {
|
||||
const original = new BailianError(
|
||||
"MCP request failed: 404 Not Found - MCP不存在或未开通",
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
try {
|
||||
rethrowWithWebSearchActivateHint(original);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BailianError);
|
||||
const wrapped = error as BailianError;
|
||||
expect(wrapped.message).toBe(original.message);
|
||||
expect(wrapped.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(wrapped.hint).toContain(MCP_WEBSEARCH_PAGE);
|
||||
expect(wrapped.cause).toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
test("已有 hint 或非未开通错误原样抛出", () => {
|
||||
const withHint = new BailianError(
|
||||
"MCP request failed: 404 Not Found - MCP不存在或未开通",
|
||||
ExitCode.GENERAL,
|
||||
"already hinted",
|
||||
);
|
||||
try {
|
||||
rethrowWithWebSearchActivateHint(withHint);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBe(withHint);
|
||||
}
|
||||
|
||||
const other = new BailianError("MCP request failed: 401 Unauthorized");
|
||||
try {
|
||||
rethrowWithWebSearchActivateHint(other);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect(error).toBe(other);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
|
||||
import type { CollectedSessionEvents, ProviderSessionEvent } from "@openagentpack/sdk";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import {
|
||||
renderCollectedEvents,
|
||||
streamAndRenderEvents,
|
||||
} from "../src/commands/managed-agent/_engine/session-render.ts";
|
||||
|
||||
/**
|
||||
* `--output json` 会话信封契约:stdout 恰好一个合法 JSON,且信封头部携带
|
||||
* session_id / provider / agent —— session run 的调用方必须能从 stdout 拿到
|
||||
* 新建 Session ID 以继续 send/get/events/delete(不靠刮 stderr)。
|
||||
*/
|
||||
|
||||
let stdoutChunks: string[] = [];
|
||||
let originalStdoutWrite: typeof process.stdout.write;
|
||||
|
||||
beforeEach(() => {
|
||||
stdoutChunks = [];
|
||||
originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdoutChunks.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
});
|
||||
|
||||
function capturedJson(): Record<string, unknown> {
|
||||
// 契约:整个 stdout 拼起来是单个合法 JSON
|
||||
return JSON.parse(stdoutChunks.join("")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function* fakeEventStream(): AsyncIterable<ProviderSessionEvent> {
|
||||
yield {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "hi",
|
||||
} as ProviderSessionEvent;
|
||||
yield { type: "status", status: "completed" } as ProviderSessionEvent;
|
||||
}
|
||||
|
||||
async function* fakeFailedEventStream(): AsyncIterable<ProviderSessionEvent> {
|
||||
yield {
|
||||
type: "error",
|
||||
content: "provider quota exceeded",
|
||||
} as ProviderSessionEvent;
|
||||
yield { type: "status", status: "failed" } as ProviderSessionEvent;
|
||||
}
|
||||
|
||||
function fakeCollected(): CollectedSessionEvents {
|
||||
return {
|
||||
terminalStatus: "completed",
|
||||
result: {
|
||||
events: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "hi",
|
||||
} as ProviderSessionEvent,
|
||||
],
|
||||
has_more: false,
|
||||
next_page: undefined,
|
||||
},
|
||||
} as CollectedSessionEvents;
|
||||
}
|
||||
|
||||
function fakeFailedCollected(): CollectedSessionEvents {
|
||||
return {
|
||||
terminalStatus: "failed",
|
||||
result: {
|
||||
events: [
|
||||
{
|
||||
type: "error",
|
||||
content: "provider quota exceeded",
|
||||
} as ProviderSessionEvent,
|
||||
],
|
||||
has_more: false,
|
||||
next_page: undefined,
|
||||
},
|
||||
} as CollectedSessionEvents;
|
||||
}
|
||||
|
||||
async function catchSessionFailure(run: () => Promise<void> | void): Promise<BailianError> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(BailianError);
|
||||
return error as BailianError;
|
||||
}
|
||||
throw new Error("expected failed session to throw");
|
||||
}
|
||||
|
||||
test("stream json:信封携带 session_id/provider/agent + events", async () => {
|
||||
await streamAndRenderEvents(fakeEventStream(), true, {
|
||||
session_id: "sess_stream",
|
||||
provider: "bailian",
|
||||
agent: "assistant",
|
||||
});
|
||||
const data = capturedJson();
|
||||
expect(data.session_id).toBe("sess_stream");
|
||||
expect(data.provider).toBe("bailian");
|
||||
expect(data.agent).toBe("assistant");
|
||||
expect(Array.isArray(data.events)).toBe(true);
|
||||
expect((data.events as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
test("streaming failed:保留 JSON 信封并以服务端 error 消息抛 GENERAL", async () => {
|
||||
const error = await catchSessionFailure(() =>
|
||||
streamAndRenderEvents(fakeFailedEventStream(), true, {
|
||||
session_id: "sess_failed_stream",
|
||||
provider: "bailian",
|
||||
agent: "assistant",
|
||||
}),
|
||||
);
|
||||
expect(error.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(error.message).toBe("provider quota exceeded");
|
||||
|
||||
const data = capturedJson();
|
||||
expect(data.session_id).toBe("sess_failed_stream");
|
||||
expect((data.events as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
test("polling json:信封携带 session_id/provider/agent,并保留 has_more/next_page", () => {
|
||||
renderCollectedEvents(fakeCollected(), true, {
|
||||
session_id: "sess_poll",
|
||||
provider: "claude",
|
||||
agent: "assistant",
|
||||
});
|
||||
const data = capturedJson();
|
||||
expect(data.session_id).toBe("sess_poll");
|
||||
expect(data.provider).toBe("claude");
|
||||
expect(data.agent).toBe("assistant");
|
||||
expect(data.has_more).toBe(false);
|
||||
expect(Array.isArray(data.events)).toBe(true);
|
||||
});
|
||||
|
||||
test("polling failed:保留 JSON 信封并以服务端 error 消息抛 GENERAL", async () => {
|
||||
const error = await catchSessionFailure(() =>
|
||||
renderCollectedEvents(fakeFailedCollected(), true, {
|
||||
session_id: "sess_failed_poll",
|
||||
provider: "claude",
|
||||
agent: "assistant",
|
||||
}),
|
||||
);
|
||||
expect(error.exitCode).toBe(ExitCode.GENERAL);
|
||||
expect(error.message).toBe("provider quota exceeded");
|
||||
|
||||
const data = capturedJson();
|
||||
expect(data.session_id).toBe("sess_failed_poll");
|
||||
expect((data.events as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
test("json:不传 context 时信封形状不变(无 session_id 键)", () => {
|
||||
renderCollectedEvents(fakeCollected(), true);
|
||||
const data = capturedJson();
|
||||
expect("session_id" in data).toBe(false);
|
||||
expect(Array.isArray(data.events)).toBe(true);
|
||||
});
|
||||
|
||||
test("text 模式:context 不影响 stdout(仍只输出助手文本)", async () => {
|
||||
await streamAndRenderEvents(fakeEventStream(), false, {
|
||||
session_id: "sess_text",
|
||||
});
|
||||
const output = stdoutChunks.join("");
|
||||
expect(output).toBe("hi");
|
||||
expect(output).not.toContain("sess_text");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.10.1",
|
||||
"version": "1.13.1",
|
||||
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -62,7 +62,11 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore {
|
||||
const configName = sources.configName;
|
||||
const activateAfterLogin = sources.flags.config !== undefined;
|
||||
return {
|
||||
describe: () => describeAuthState(sources),
|
||||
// Read the config block live (not the startup `sources.file` snapshot) so
|
||||
// long-lived processes like `config ui` reflect a fresh login without a
|
||||
// restart. For one-shot commands this reads the same content the snapshot
|
||||
// held, so behavior is unchanged.
|
||||
describe: () => describeAuthState({ ...sources, file: readConfigFile(configName) }),
|
||||
stored() {
|
||||
const file = readConfigFile(configName);
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user