diff --git a/.gitignore b/.gitignore index f243c8a..d852ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ packages/cli/scene/**/outputs/ # Environment variables (sensitive data) .env + +# Local scratch / plan drafts (never commit) +.scratch/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e71db6..b2df1e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index af5b800..141e213 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -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 ### 变更 diff --git a/README.md b/README.md index 1e1e7a4..75a7c23 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README.zh.md b/README.zh.md index 0c1e802..1a592a2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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 张图参考)/ 自然语言视频编辑 diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index 4e79e7d..b9b00db 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -46,7 +46,9 @@ - `config list` 标识所有 Profile 与当前激活项。 - `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。 - `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。 -- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。 +- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。 +- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。 +- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。 - 同步 E2E topic routes、Skill setup 和自动生成 reference。 ## 6. 最小测试矩阵 @@ -62,7 +64,8 @@ `--config default` 成功后切回 `default`。 - Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。 - `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。 -- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。 +- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。 +- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。 ## 7. 完成检查 diff --git a/docs/agents/url-change.md b/docs/agents/url-change.md index 3f032c4..3295cba 100644 --- a/docs/agents/url-change.md +++ b/docs/agents/url-change.md @@ -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/ core/files/upload.ts ← 文件上传 endpoint(cn-pinned) UPLOAD_API ${REGIONS.cn}/api/v1/uploads diff --git a/packages/cli/README.md b/packages/cli/README.md index 1e1e7a4..75a7c23 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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) diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 0c1e802..1a592a2 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -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 张图参考)/ 自然语言视频编辑 diff --git a/packages/cli/package.json b/packages/cli/package.json index 1ec385f..d7eff6a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", @@ -25,7 +25,8 @@ }, "files": [ "dist", - "README.zh.md" + "README.zh.md", + "postinstall.js" ], "type": "module", "exports": { @@ -45,12 +46,14 @@ "build": "vp pack", "dev": "tsx src/main.ts", "test": "vp test", - "check": "vp check" + "check": "vp check", + "postinstall": "node postinstall.js" }, "dependencies": { "bailian-cli-commands": "workspace:*", "bailian-cli-core": "workspace:*", - "bailian-cli-runtime": "workspace:*" + "bailian-cli-runtime": "workspace:*", + "tar-stream": "catalog:" }, "devDependencies": { "@clack/prompts": "^0.7.0", diff --git a/packages/cli/postinstall.js b/packages/cli/postinstall.js new file mode 100644 index 0000000..a502a0b --- /dev/null +++ b/packages/cli/postinstall.js @@ -0,0 +1,213 @@ +/** + * postinstall.js — Wiki data sync (layer 1: triggered by npm install) + * + * Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data + * package and overwrites the local directory, ensuring data is in place the first time the user runs + * `bl advisor recommend`. + * + * Flow (unified skill publishing protocol: skills/index.json + one content-addressed object per skill): + * 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry + * 2. Download skills/bailian-docs-llm-wiki/ (sha256-.tar.br, brotli q6, ~2.3MB); + * legacy fallback to skill.tar.br when the entry has no valid object field + * 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir + * 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/ + * 5. Write ~/.bailian/wiki-sync-state.json + * 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill) + * + * Design constraints: + * - Unconditional overwrite: every install fully replaces, no version comparison + * - Silent failure: any step failure → console.warn → process.exit(0), never blocks install + * - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling + * - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs) + */ +import { + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createBrotliDecompress } from "node:zlib"; +import tar from "tar-stream"; + +const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills"; +const WIKI_SKILL_NAME = "bailian-docs-llm-wiki"; +const CONFIG_DIR_NAME = ".bailian"; +const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki"; +const STATE_FILE_NAME = "wiki-sync-state.json"; +const INDEX_KEY = "index.json"; +/** Legacy fixed asset key (entries without a valid content-addressed object field) */ +const LEGACY_ASSET_NAME = "skill.tar.br"; +/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */ +const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/; + +const INDEX_TIMEOUT_MS = 3000; +const DOWNLOAD_TIMEOUT_MS = 30000; + +function getConfigDir() { + if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR; + return join(homedir(), CONFIG_DIR_NAME); +} + +function getCatalogDir() { + return join(getConfigDir(), SKILL_DIR_NAME); +} + +function getStatePath() { + return join(getConfigDir(), STATE_FILE_NAME); +} + +function getSkillLockPath() { + return join(getConfigDir(), "skills", "skill-lock.json"); +} + +/** + * Record this sync in skill-lock.json (same ledger as bl skill; list shows installed). + * Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing + * entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized. + * best-effort: failure does not affect data sync results. + */ +function upsertSkillLock(name, entry) { + try { + let lock = { version: 1, skills: {} }; + try { + const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8")); + if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") { + lock = parsed; + } + } catch { + /* absent/corrupted → empty table */ + } + lock.skills[name] = { ...lock.skills[name], ...entry }; + mkdirSync(dirname(getSkillLockPath()), { recursive: true }); + writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n"); + } catch { + /* Bookkeeping failure does not block install; advisor-side sync will backfill */ + } +} + +async function fetchJson(url, timeoutMs) { + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +async function downloadBuffer(url) { + const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */ +function isSafeEntryName(name) { + if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false; + return !name.split("/").includes(".."); +} + +/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */ +async function extractTarBr(tarBrBuffer, destDir) { + const extract = tar.extract(); + + extract.on("entry", (header, stream, next) => { + if (!isSafeEntryName(header.name)) { + // Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this + // error; silence the entry stream to avoid its companion error becoming unhandled + stream.on("error", () => {}); + stream.resume(); + extract.destroy(new Error(`unsafe tar entry: ${header.name}`)); + return; + } + const filePath = join(destDir, header.name); + if (header.type === "directory") { + mkdirSync(filePath, { recursive: true }); + stream.resume(); + stream.on("end", next); + return; + } + mkdirSync(dirname(filePath), { recursive: true }); + const ws = createWriteStream(filePath); + stream.pipe(ws); + ws.on("finish", next); + ws.on("error", next); + }); + + await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract); +} + +/** Atomic swap: tmpDir (same volume) → catalogDir. */ +function atomicSwap(tmpDir, catalogDir) { + mkdirSync(dirname(catalogDir), { recursive: true }); + const backup = `${catalogDir}.old-${Date.now()}`; + if (existsSync(catalogDir)) renameSync(catalogDir, backup); + try { + renameSync(tmpDir, catalogDir); + } catch (err) { + if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir); + throw err; + } + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); +} + +async function main() { + // 1. Download skills/index.json and get the wiki entry + const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS); + const entry = index?.skills?.[WIKI_SKILL_NAME]; + if (!entry?.contentHash) + throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json"); + + // 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback + const assetName = + entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME; + const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`); + + // 3. Extract to same-volume temp dir + atomic swap + const catalogDir = getCatalogDir(); + const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`; + try { + mkdirSync(tmpDir, { recursive: true }); + await extractTarBr(tarBuf, tmpDir); + atomicSwap(tmpDir, catalogDir); + } catch (err) { + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); + throw err; + } + + // 4. Write state + try { + writeFileSync( + getStatePath(), + JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }), + ); + } catch { + /* state write failure has no impact: first recommend will re-check */ + } + + // 5. skill-lock.json record: wiki shares the same ledger as bl skill + upsertSkillLock(WIKI_SKILL_NAME, { + contentHash: entry.contentHash, + ...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}), + installedAt: new Date().toISOString(), + sourceType: "oss", + ...(entry.description ? { description: entry.description } : {}), + }); + + process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`); +} + +main().catch((err) => { + // Unconditional pass-through: install-time network/permission issues should not block npm install; + // sync.ts will fall back to syncing on the first `bl advisor recommend`. + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`, + ); + // Force a success exit code so a download failure never fails `npm install`. + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 0ef8097..2cbda30 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -89,6 +89,10 @@ import { pluginLink, pluginList, pluginRemove, + skillAdd, + skillUpdate, + skillRemove, + skillList, managedAgentInit, managedAgentValidate, managedAgentPlan, @@ -203,6 +207,10 @@ export const commands: Record = { "plugin link": pluginLink, "plugin list": pluginList, "plugin remove": pluginRemove, + "skill add": skillAdd, + "skill update": skillUpdate, + "skill remove": skillRemove, + "skill list": skillList, "managed-agent init": managedAgentInit, "managed-agent validate": managedAgentValidate, "managed-agent plan": managedAgentPlan, diff --git a/packages/commands/package.json b/packages/commands/package.json index 6e02dcd..1fae49d 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -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": { diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index c848a51..c328b06 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -6,6 +6,7 @@ import { type GetModelsOptions, getModels, type IntentProfile, + maybeSyncWikiData, type PipelineStep, type RecommendedModel, type RecommendResult, @@ -248,6 +249,12 @@ export default defineCommand({ const { settings, flags } = ctx; const userInput = flags.message; const top = 3; + + // Keep the local wiki catalog fresh: throttled (12h) version check against + // the remote manifest, silently replaces data when a newer version exists. + // Never throws — a sync failure must not block recommendation. + await maybeSyncWikiData(); + // Default to JSON for structured output; render boxen cards only when the // user explicitly asked for text output. const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json"; diff --git a/packages/commands/src/commands/auth/console-ui.ts b/packages/commands/src/commands/auth/console-ui.ts new file mode 100644 index 0000000..dd5a498 --- /dev/null +++ b/packages/commands/src/commands/auth/console-ui.ts @@ -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; +} + +/** 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"); + }, + }; +} diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts index 6200f8e..91204ed 100644 --- a/packages/commands/src/commands/auth/login-api-key.ts +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -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", }, }; diff --git a/packages/commands/src/commands/config/agent-launch.ts b/packages/commands/src/commands/config/agent-launch.ts new file mode 100644 index 0000000..ad38040 --- /dev/null +++ b/packages/commands/src/commands/config/agent-launch.ts @@ -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 = { + "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 ""` (execute prompt, stay interactive) + * - claude-code: `claude ""` (positional initial prompt) + * - codex: `codex ""` (positional initial prompt) + */ +const AGENT_PROMPT_ARGV: Record 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/commands/src/commands/config/agent/decode-key.ts b/packages/commands/src/commands/config/agent/decode-key.ts new file mode 100644 index 0000000..53b6d0d --- /dev/null +++ b/packages/commands/src/commands/config/agent/decode-key.ts @@ -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(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; +} diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index b83e11a..54bb55c 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -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: "", description: "API base URL", required: true }, - apiKey: { type: "string", valueHint: "", description: "API key", required: true }, + baseUrl: { + type: "string", + valueHint: "", + description: "API base URL", + }, + region: { + type: "string", + valueHint: "", + description: + "Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only", + }, + apiKey: { + type: "string", + valueHint: "", + description: "API key", + }, + key: { + type: "string", + valueHint: "", + description: + 'Obfuscated API key from the web console (starts with "o1_"); decoded into --api-key', + }, model: { type: "string", valueHint: "", description: "Default model name", required: true, }, + contextWindow: { + type: "number", + valueHint: "", + description: "OpenClaw only: model context window in tokens (default: 256000)", + }, + wireApi: { + type: "string", + valueHint: "", + 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 --base-url --api-key --model ", + usageArgs: + "--agent (--base-url | --region ) (--api-key | --key ) --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`); + } } }, }); diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts index 938f84b..cd4a990 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -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, 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; - 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; diff --git a/packages/commands/src/commands/config/agent/writers/codex.ts b/packages/commands/src/commands/config/agent/writers/codex.ts index 5353d99..bb5c106 100644 --- a/packages/commands/src/commands/config/agent/writers/codex.ts +++ b/packages/commands/src/commands/config/agent/writers/codex.ts @@ -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; const existing = (providers[PROVIDER_KEY] ?? {}) as Record; @@ -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; diff --git a/packages/commands/src/commands/config/agent/writers/hermes.ts b/packages/commands/src/commands/config/agent/writers/hermes.ts index ae2de7a..73ae519 100644 --- a/packages/commands/src/commands/config/agent/writers/hermes.ts +++ b/packages/commands/src/commands/config/agent/writers/hermes.ts @@ -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 = { + 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>) - : []; - 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)); diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 71ec8c5..57d44c3 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -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 | undefined { + const model = defaults.model; + if (!model || typeof model !== "object") return undefined; + const primary = (model as Record).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; models.mode = "merge"; const providers = (models.providers ?? {}) as Record; 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; const defaults = (agents.defaults ?? {}) as Record; - defaults.model = { primary: `bailian-cli/${model}` }; + const allowedModels = (defaults.models ?? {}) as Record; + 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; diff --git a/packages/commands/src/commands/config/agent/writers/opencode.ts b/packages/commands/src/commands/config/agent/writers/opencode.ts index 87b729c..cc64b49 100644 --- a/packages/commands/src/commands/config/agent/writers/opencode.ts +++ b/packages/commands/src/commands/config/agent/writers/opencode.ts @@ -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"; diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts index 437f19f..42e6ae7 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -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): 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; env[ENV_KEY] = apiKey; settings.env = env; - // modelProviders[] — 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[] — upsert only bailian-cli-owned entries. const providers = (settings.modelProviders ?? {}) as Record< string, Array> >; const entries = (providers[protocol] ?? []) as Array>; - 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; 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; diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index bbc6a37..32f3d04 100644 --- a/packages/commands/src/commands/config/agent/writers/utils.ts +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -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 { if (!existsSync(path)) return {}; @@ -30,6 +114,16 @@ export function readJson(path: string): Record { } } +/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */ +export function readJsonc(path: string): Record { + if (!existsSync(path)) return {}; + try { + return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record; + } 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`; +} diff --git a/packages/commands/src/commands/config/assets.ts b/packages/commands/src/commands/config/assets.ts new file mode 100644 index 0000000..17c2938 --- /dev/null +++ b/packages/commands/src/commands/config/assets.ts @@ -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 = { + ".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 = { + ".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; +} diff --git a/packages/commands/src/commands/config/inventory.ts b/packages/commands/src/commands/config/inventory.ts new file mode 100644 index 0000000..5ec9c49 --- /dev/null +++ b/packages/commands/src/commands/config/inventory.ts @@ -0,0 +1,1018 @@ +// Read-only discovery of locally installed AI tooling, surfaced by `config ui`: +// - Agent skills installed under ~/.agents/skills (via `npx skills add`). +// - MCP servers declared in each coding agent's local config file. +// - Coding agent frameworks and whether the bailian-cli provider is wired in. +// +// Everything here only reads the filesystem; nothing is written. Missing files, +// unreadable dirs and malformed configs degrade to empty results rather than +// throwing, so a broken third-party config never takes down the UI. +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, + type Dirent, +} from "node:fs"; +import { inflateRawSync } from "node:zlib"; +import yaml from "yaml"; +import { parse as parseToml } from "smol-toml"; + +/** + * Where an item comes from. Everything discovered on disk today is `local`; + * `remote` is reserved for entries later loaded from an online URL. + */ +export type ItemOrigin = "local" | "remote"; + +/** A skill discovered in one or more agent skill directories. */ +export interface SkillInfo { + id: string; + name: string; + description: string; + version?: string; + fileCount: number; + path: string; + /** Agent modules this skill is installed in (e.g. global, claude-code, qwen-code). */ + sources: string[]; + /** local (on disk) or remote (loaded from a URL). */ + origin: ItemOrigin; +} + +/** One MCP server entry pulled from an agent's local config. */ +export interface McpServerInfo { + name: string; + source: string; + transport: "stdio" | "http" | "sse" | "unknown"; + detail: string; + scope: string; + /** local (on disk) or remote (loaded from a URL). */ + origin: ItemOrigin; + /** Raw config entry for this server, with secret-looking values masked. */ + config?: Record; + /** Whether this entry lives in a JSON config we can edit/write back here. */ + editable: boolean; +} + +/** A coding agent framework and its local configuration state. */ +export interface AgentInfo { + id: string; + label: string; + installed: boolean; + configured: boolean; + model?: string; + paths: string[]; + origin: ItemOrigin; +} + +function readText(path: string): string | undefined { + try { + return readFileSync(path, "utf-8"); + } catch { + return undefined; + } +} + +function readJsonSafe(path: string): Record | undefined { + const text = readText(path); + if (text === undefined) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +// ---- Skills ---- + +/** Extract the leading `--- ... ---` YAML frontmatter block from a SKILL.md. */ +function parseFrontmatter(md: string): Record { + const match = /^---\s*\n([\s\S]*?)\n---/.exec(md); + if (!match) return {}; + try { + return asRecord(yaml.parse(match[1])) ?? {}; + } catch { + return {}; + } +} + +function countFiles(dir: string, budget = 500): number { + let total = 0; + const walk = (current: string): void => { + if (total >= budget) return; + let entries: Dirent[]; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (total >= budget) return; + const full = join(current, entry.name); + if (entry.isDirectory()) walk(full); + else total += 1; + } + }; + walk(dir); + return total; +} + +/** + * Skill directories to scan, keyed by the module that owns them. `npx skills + * add --all` fans skills out into each installed agent, so the same skill can + * live in several of these roots at once. + */ +function skillRoots(home: string): Array<{ source: string; dir: string }> { + return [ + { source: "global", dir: join(home, ".agents", "skills") }, + { source: "claude-code", dir: join(home, ".claude", "skills") }, + { source: "cursor", dir: join(home, ".cursor", "skills") }, + { source: "qwen-code", dir: join(home, ".qwen", "skills") }, + { source: "codex", dir: join(home, ".codex", "skills") }, + { source: "opencode", dir: join(home, ".config", "opencode", "skills") }, + { source: "openclaw", dir: join(home, ".openclaw", "skills") }, + { source: "openclaw", dir: join(home, ".openclaw", "workspace", "skills") }, + { source: "hermes", dir: join(home, ".hermes", "skills") }, + { source: "gemini", dir: join(home, ".gemini", "skills") }, + { + source: "antigravity", + dir: join(home, ".gemini", "antigravity", "skills"), + }, + { source: "windsurf", dir: join(home, ".windsurf", "skills") }, + { source: "windsurf", dir: join(home, ".codeium", "windsurf", "skills") }, + { source: "qoderwork", dir: join(home, ".qoderwork", "skills") }, + { source: "workbuddy", dir: join(home, ".workbuddy", "skills") }, + ]; +} + +/** + * List skills installed across every known agent skill directory, aggregated + * by skill id. Each skill records the modules (`sources`) it is installed in; + * metadata is taken from the first module found (roots are ordered global-first). + */ +export function listSkills(home: string = homedir()): SkillInfo[] { + const byId = new Map(); + + for (const { source, dir: root } of skillRoots(home)) { + let entries: Dirent[]; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + // Accept real dirs and symlinks: `skills add` fans skills into each agent + // as symlinks back to ~/.agents/skills, and isDirectory() is false for those. + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const dir = join(root, entry.name); + const skillMd = join(dir, "SKILL.md"); + if (!existsSync(skillMd)) continue; + + const existing = byId.get(entry.name); + if (existing) { + if (!existing.sources.includes(source)) existing.sources.push(source); + continue; + } + + const fm = parseFrontmatter(readText(skillMd) ?? ""); + const meta = asRecord(fm.metadata); + const description = typeof fm.description === "string" ? fm.description.trim() : ""; + const version = + meta && typeof meta.version === "string" + ? meta.version + : typeof fm.version === "string" + ? fm.version + : undefined; + + byId.set(entry.name, { + id: entry.name, + name: typeof fm.name === "string" && fm.name ? fm.name : entry.name, + description, + version, + fileCount: countFiles(dir), + path: dir, + sources: [source], + origin: "local", + }); + } + } + + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** A skill plus the raw text of its SKILL.md, for the detail drawer. */ +export interface SkillDetail extends SkillInfo { + content: string; +} + +/** + * Return full detail for one discovered skill (by id), including the raw + * SKILL.md content. The id must match a skill found by `listSkills`, so the + * read is confined to a known skill directory. Returns null when not found. + */ +export function getSkillDetail(id: string, home: string = homedir()): SkillDetail | null { + const skill = listSkills(home).find((s) => s.id === id); + if (!skill) return null; + const content = readText(join(skill.path, "SKILL.md")) ?? ""; + return { ...skill, content }; +} + +// ---- Skill install (upload a .zip and unpack it into a skill root) ---- + +/** Allow-listed skill install targets: source id -> label + path segments. */ +const SKILL_INSTALL_TARGETS: Array<{ + source: string; + label: string; + sub: string[]; +}> = [ + { + source: "global", + label: "All agents (~/.agents/skills)", + sub: [".agents", "skills"], + }, + { source: "claude-code", label: "Claude Code", sub: [".claude", "skills"] }, + { source: "qwen-code", label: "Qwen Code", sub: [".qwen", "skills"] }, + { source: "codex", label: "Codex", sub: [".codex", "skills"] }, + { + source: "opencode", + label: "OpenCode", + sub: [".config", "opencode", "skills"], + }, + { source: "openclaw", label: "OpenClaw", sub: [".openclaw", "skills"] }, + { source: "qoderwork", label: "QoderWork", sub: [".qoderwork", "skills"] }, + { + source: "windsurf", + label: "Windsurf", + sub: [".codeium", "windsurf", "skills"], + }, + { source: "gemini", label: "Gemini", sub: [".gemini", "skills"] }, +]; + +/** The list of install targets exposed to the UI (source + human label). */ +export function skillInstallTargets(): Array<{ + source: string; + label: string; +}> { + return SKILL_INSTALL_TARGETS.map((t) => ({ + source: t.source, + label: t.label, + })); +} + +function skillInstallRoot(source: string, home: string): string | null { + const t = SKILL_INSTALL_TARGETS.find((x) => x.source === source); + return t ? join(home, ...t.sub) : null; +} + +interface ZipEntry { + name: string; + data: Buffer; +} + +/** + * Minimal ZIP reader (no external deps). Walks the central directory for + * correct sizes/offsets, then inflates each entry (stored or deflate). Zip64 + * and encryption are unsupported and will throw. Sufficient for skill packages. + */ +function parseZip(buf: Buffer): ZipEntry[] { + const EOCD_SIG = 0x06054b50; + let eocd = -1; + const minStart = Math.max(0, buf.length - 22 - 0xffff); + for (let i = buf.length - 22; i >= minStart; i--) { + if (buf.readUInt32LE(i) === EOCD_SIG) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error("Not a valid .zip archive."); + const count = buf.readUInt16LE(eocd + 10); + let off = buf.readUInt32LE(eocd + 16); + const entries: ZipEntry[] = []; + for (let e = 0; e < count; e++) { + if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) break; + const method = buf.readUInt16LE(off + 10); + const compSize = buf.readUInt32LE(off + 20); + const nameLen = buf.readUInt16LE(off + 28); + const extraLen = buf.readUInt16LE(off + 30); + const commentLen = buf.readUInt16LE(off + 32); + const localOff = buf.readUInt32LE(off + 42); + const name = buf.toString("utf8", off + 46, off + 46 + nameLen); + off += 46 + nameLen + extraLen + commentLen; + if (name.endsWith("/")) continue; + if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) continue; + const lNameLen = buf.readUInt16LE(localOff + 26); + const lExtraLen = buf.readUInt16LE(localOff + 28); + const dataStart = localOff + 30 + lNameLen + lExtraLen; + const raw = buf.subarray(dataStart, dataStart + compSize); + let data: Buffer; + if (method === 0) data = Buffer.from(raw); + else if (method === 8) data = inflateRawSync(raw); + else throw new Error("Unsupported compression in archive (entry: " + name + ")."); + entries.push({ name, data }); + } + return entries; +} + +/** + * Install a skill from an uploaded .zip into the chosen agent's skills root. + * The archive must contain a SKILL.md at its root or inside one top-level + * folder. Entry paths are sanitized (no absolute paths, no `..`) and every + * write is confined to the target skill directory (zip-slip safe). + */ +export function installSkillZip( + source: string, + zip: Buffer, + nameHint: string, + home: string = homedir(), +): { installed: string; files: number; dir: string } { + const root = skillInstallRoot(source, home); + if (!root) throw new Error("Unknown skill install target."); + + const norm = parseZip(zip) + .map((e) => ({ + name: e.name.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""), + data: e.data, + })) + .filter((e) => e.name && !e.name.endsWith("/") && !e.name.split("/").includes("..")); + if (!norm.length) throw new Error("The archive contains no usable files."); + + const skillMd = norm.find((e) => e.name === "SKILL.md" || e.name.endsWith("/SKILL.md")); + if (!skillMd) throw new Error("No SKILL.md found in the archive (root or a top-level folder)."); + + const slash = skillMd.name.lastIndexOf("/"); + let skillName: string; + let prefix: string; + if (slash < 0) { + skillName = (nameHint || "").trim(); + if (!skillName) + throw new Error("SKILL.md is at the archive root \u2014 please provide a skill name."); + prefix = ""; + } else { + prefix = skillMd.name.slice(0, slash + 1); + skillName = (nameHint || "").trim() || prefix.split("/")[0]; + } + if (!/^[A-Za-z0-9._-]+$/.test(skillName)) throw new Error("Invalid skill name: " + skillName); + + const targetDir = join(root, skillName); + const base = resolve(targetDir); + let files = 0; + for (const e of norm) { + let rel = e.name; + if (prefix) { + if (!e.name.startsWith(prefix)) continue; + rel = e.name.slice(prefix.length); + } + if (!rel) continue; + const abs = resolve(join(targetDir, rel)); + if (abs !== base && !abs.startsWith(base + sep)) continue; // zip-slip guard + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, e.data); + files++; + } + if (files === 0) throw new Error("Nothing was extracted from the archive."); + return { installed: skillName, files, dir: targetDir }; +} + +// ---- MCP servers ---- + +function transportOf(entry: Record): { + transport: McpServerInfo["transport"]; + detail: string; +} { + if (typeof entry.command === "string") { + const args = Array.isArray(entry.args) ? entry.args.join(" ") : ""; + return { transport: "stdio", detail: `${entry.command} ${args}`.trim() }; + } + const url = typeof entry.url === "string" ? entry.url : undefined; + if (url) { + const type = typeof entry.type === "string" ? entry.type.toLowerCase() : ""; + return { transport: type === "sse" ? "sse" : "http", detail: url }; + } + return { transport: "unknown", detail: "" }; +} + +function collectMcpMap( + raw: unknown, + source: string, + scope: string, + out: McpServerInfo[], + editable: boolean, +): void { + const map = asRecord(raw); + if (!map) return; + for (const [name, value] of Object.entries(map)) { + const entry = asRecord(value) ?? {}; + const { transport, detail } = transportOf(entry); + out.push({ + name, + source, + transport, + detail, + scope, + origin: "local", + config: maskConfigSecrets(entry) as Record, + editable, + }); + } +} + +/** Discover MCP servers declared across local agent config files. */ +export function listMcpServers(home: string = homedir()): McpServerInfo[] { + const out: McpServerInfo[] = []; + + // Claude Code: global mcpServers + per-project mcpServers in ~/.claude.json. + const claude = readJsonSafe(join(home, ".claude.json")); + if (claude) { + collectMcpMap(claude.mcpServers, "claude-code", "global", out, true); + const projects = asRecord(claude.projects); + if (projects) { + for (const [projectPath, projectValue] of Object.entries(projects)) { + const project = asRecord(projectValue); + if (project?.mcpServers) + collectMcpMap(project.mcpServers, "claude-code", projectPath, out, true); + } + } + } + + // Qwen Code. + const qwen = readJsonSafe(join(home, ".qwen", "settings.json")); + if (qwen) collectMcpMap(qwen.mcpServers, "qwen-code", "global", out, true); + + // OpenCode uses `mcp` rather than `mcpServers`. + const opencode = readJsonSafe(join(home, ".config", "opencode", "opencode.json")); + if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out, true); + + // Cursor, Windsurf, Gemini, QoderWork, OpenClaw, Claude Desktop: all JSON with + // a top-level `mcpServers` map (Claude/Cursor convention). + const cursor = readJsonSafe(join(home, ".cursor", "mcp.json")); + if (cursor) collectMcpMap(cursor.mcpServers, "cursor", "global", out, true); + + const windsurf = readJsonSafe(join(home, ".codeium", "windsurf", "mcp_config.json")); + if (windsurf) collectMcpMap(windsurf.mcpServers, "windsurf", "global", out, true); + + const gemini = readJsonSafe(join(home, ".gemini", "settings.json")); + if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true); + + const qoder = readJsonSafe(join(home, ".qoderwork", "mcp.json")); + if (qoder) collectMcpMap(qoder.mcpServers, "qoderwork", "global", out, true); + + const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json")); + if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true); + + const claudeDesktop = readJsonSafe(claudeDesktopConfigPath(home)); + if (claudeDesktop) collectMcpMap(claudeDesktop.mcpServers, "claude-desktop", "global", out, true); + + // Codex declares servers as [mcp_servers.] TOML tables. + const codexToml = readText(join(home, ".codex", "config.toml")); + if (codexToml) { + try { + const parsed = parseToml(codexToml) as Record; + collectMcpMap(parsed.mcp_servers, "codex", "global", out, false); + } catch { + /* ignore malformed toml */ + } + } + + return out.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); +} + +// ---- MCP write-back (edit / add / delete) ---- + +const MCP_MASK_CHAR = "\u2022"; + +/** Platform-specific Claude Desktop config path. */ +function claudeDesktopConfigPath(home: string): string { + if (process.platform === "darwin") + return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"); + if (process.platform === "win32") + return join( + process.env.APPDATA ?? join(home, "AppData", "Roaming"), + "Claude", + "claude_desktop_config.json", + ); + return join(home, ".config", "Claude", "claude_desktop_config.json"); +} + +interface McpWriteTarget { + file: string; + mapKey: string; + /** Claude stores project-scoped servers under projects[scope][mapKey]. */ + projectScoped: boolean; +} + +/** + * Map a (source, scope) pair to the JSON config file and map key to edit. Only + * JSON-backed sources are writable; Codex (TOML) and unknown sources return + * null so the UI keeps them read-only. + */ +function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTarget | null { + if (source === "claude-code") + return { + file: join(home, ".claude.json"), + mapKey: "mcpServers", + projectScoped: scope !== "global", + }; + if (source === "qwen-code") + return { + file: join(home, ".qwen", "settings.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "opencode") + return { + file: join(home, ".config", "opencode", "opencode.json"), + mapKey: "mcp", + projectScoped: false, + }; + if (source === "cursor") + return { + file: join(home, ".cursor", "mcp.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "windsurf") + return { + file: join(home, ".codeium", "windsurf", "mcp_config.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "gemini") + return { + file: join(home, ".gemini", "settings.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "qoderwork") + return { + file: join(home, ".qoderwork", "mcp.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "openclaw") + return { + file: join(home, ".openclaw", "openclaw.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "claude-desktop") + return { + file: claudeDesktopConfigPath(home), + mapKey: "mcpServers", + projectScoped: false, + }; + return null; +} + +/** + * Merge a submitted config with the stored one: any string still carrying the + * mask char is treated as unchanged and restored from `stored`. Throws if a + * masked value has no stored counterpart, so bullet placeholders are never + * persisted as a real secret. + */ +function unmaskMcpConfig(submitted: unknown, stored: unknown): unknown { + if (typeof submitted === "string") { + if (submitted.includes(MCP_MASK_CHAR)) { + if (typeof stored === "string") return stored; + throw new Error( + "Replace masked values (\u2022\u2022\u2022) with the real value before saving.", + ); + } + return submitted; + } + if (Array.isArray(submitted)) { + const arr = Array.isArray(stored) ? stored : []; + return submitted.map((v, i) => unmaskMcpConfig(v, arr[i])); + } + const rec = asRecord(submitted); + if (rec) { + const storedRec = asRecord(stored) ?? {}; + const out: Record = {}; + for (const [k, v] of Object.entries(rec)) out[k] = unmaskMcpConfig(v, storedRec[k]); + return out; + } + return submitted; +} + +/** Create or update one MCP server entry, writing back to its source file. */ +export function writeMcpServer( + source: string, + scope: string, + name: string, + config: unknown, + home: string = homedir(), +): void { + const target = mcpWriteTarget(source, scope, home); + if (!target) throw new Error("This MCP source is read-only and cannot be edited here."); + const trimmed = name.trim(); + if (!trimmed) throw new Error("Server name is required."); + const cfg = asRecord(config); + if (!cfg) throw new Error("Config must be a JSON object."); + + const root = readJsonSafe(target.file) ?? {}; + let container: Record = root; + if (target.projectScoped) { + const projects = asRecord(root.projects) ?? {}; + root.projects = projects; + const proj = asRecord(projects[scope]) ?? {}; + projects[scope] = proj; + container = proj; + } + const map = asRecord(container[target.mapKey]) ?? {}; + container[target.mapKey] = map; + + map[trimmed] = unmaskMcpConfig(cfg, asRecord(map[trimmed])); + + mkdirSync(dirname(target.file), { recursive: true }); + writeFileSync(target.file, JSON.stringify(root, null, 2) + "\n", "utf-8"); +} + +/** Remove one MCP server entry from its source file. */ +export function deleteMcpServer( + source: string, + scope: string, + name: string, + home: string = homedir(), +): void { + const target = mcpWriteTarget(source, scope, home); + if (!target) throw new Error("This MCP source is read-only and cannot be edited here."); + const root = readJsonSafe(target.file); + if (!root) throw new Error("Config file not found."); + let container: Record | undefined = root; + if (target.projectScoped) { + const projects = asRecord(root.projects); + container = projects ? asRecord(projects[scope]) : undefined; + } + const map = container ? asRecord(container[target.mapKey]) : undefined; + if (!map || !(name in map)) throw new Error("Server not found: " + name); + delete map[name]; + // Don't leave an empty map behind if this was the last server. + if (container && Object.keys(map).length === 0) delete container[target.mapKey]; + writeFileSync(target.file, JSON.stringify(root, null, 2) + "\n", "utf-8"); +} + +// ---- Agent frameworks ---- + +interface AgentProbe { + id: string; + label: string; + /** Config paths that mark the agent as installed (any existing → installed). */ + paths: (home: string) => string[]; + /** Inspect config to decide whether the bailian-cli provider is wired in. */ + detect: (home: string) => { configured: boolean; model?: string }; +} + +const AGENT_PROBES: AgentProbe[] = [ + { + id: "claude-code", + label: "Claude Code", + // The agent writer honors CLAUDE_CONFIG_DIR, so probe the same location. + paths: (h) => [ + join(process.env.CLAUDE_CONFIG_DIR || join(h, ".claude"), "settings.json"), + join(h, ".claude.json"), + ], + detect: (h) => { + const configDir = process.env.CLAUDE_CONFIG_DIR || join(h, ".claude"); + const env = asRecord(readJsonSafe(join(configDir, "settings.json"))?.env); + const baseUrl = + env && typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : undefined; + const model = + env && typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : undefined; + return { configured: Boolean(baseUrl), model }; + }, + }, + { + id: "qwen-code", + label: "Qwen Code", + paths: (h) => [join(h, ".qwen", "settings.json")], + detect: (h) => { + const settings = readJsonSafe(join(h, ".qwen", "settings.json")); + const providers = asRecord(settings?.modelProviders); + // The writer brands entries either "bailian-cli" (legacy) or with a + // "[Bailian] " display name. + const hasBailian = providers + ? Object.values(providers).some( + (list) => + Array.isArray(list) && + list.some((entry) => { + const name = asRecord(entry)?.name; + return ( + typeof name === "string" && + (name === "bailian-cli" || name.startsWith("[Bailian]")) + ); + }), + ) + : false; + const model = asRecord(settings?.model); + return { + configured: hasBailian, + model: model && typeof model.name === "string" ? model.name : undefined, + }; + }, + }, + { + id: "opencode", + label: "OpenCode", + paths: (h) => [join(h, ".config", "opencode", "opencode.json")], + detect: (h) => { + const provider = asRecord( + readJsonSafe(join(h, ".config", "opencode", "opencode.json"))?.provider, + ); + const bailian = asRecord(provider?.["bailian-cli"]); + const models = asRecord(bailian?.models); + return { + configured: Boolean(bailian), + model: models ? Object.keys(models)[0] : undefined, + }; + }, + }, + { + id: "openclaw", + label: "OpenClaw", + paths: (h) => [join(h, ".openclaw", "openclaw.json")], + detect: (h) => { + const models = asRecord(readJsonSafe(join(h, ".openclaw", "openclaw.json"))?.models); + const providers = asRecord(models?.providers); + const bailian = asRecord(providers?.["bailian-cli"]); + const list = Array.isArray(bailian?.models) ? bailian.models : []; + const first = asRecord(list[0]); + return { + configured: Boolean(bailian), + model: first && typeof first.id === "string" ? first.id : undefined, + }; + }, + }, + { + id: "hermes", + label: "Hermes Agent", + paths: (h) => [join(h, ".hermes", "config.yaml")], + detect: (h) => { + const text = readText(join(h, ".hermes", "config.yaml")); + if (!text) return { configured: false }; + let config: Record | undefined; + try { + config = asRecord(yaml.parse(text)); + } catch { + return { configured: false }; + } + const providers = Array.isArray(config?.custom_providers) ? config.custom_providers : []; + const legacyConfigured = providers.some( + (provider) => asRecord(provider)?.name === "bailian-cli", + ); + // The writer now emits the official flat `model.*` block (provider + // "custom" + a DashScope/Token Plan base_url); keep detecting legacy + // custom_providers entries written by older CLI versions. + const model = asRecord(config?.model); + const flatConfigured = Boolean( + model && + model.provider === "custom" && + typeof model.base_url === "string" && + model.base_url.includes("aliyuncs.com"), + ); + return { + configured: legacyConfigured || flatConfigured, + model: model && typeof model.default === "string" ? model.default : undefined, + }; + }, + }, + { + id: "codex", + label: "Codex", + paths: (h) => [join(h, ".codex", "config.toml"), join(h, ".codex", "auth.json")], + detect: (h) => { + const text = readText(join(h, ".codex", "config.toml")); + if (!text) return { configured: false }; + let config: Record = {}; + try { + config = parseToml(text) as Record; + } catch { + return { configured: false }; + } + const providers = asRecord(config.model_providers); + const configured = Boolean(providers?.["bailian-cli"]); + return { + configured, + model: typeof config.model === "string" ? config.model : undefined, + }; + }, + }, +]; + +/** Report each known coding agent framework and its local config state. */ +export function listAgents(home: string = homedir()): AgentInfo[] { + return AGENT_PROBES.map((probe) => { + const paths = probe.paths(home); + const installed = paths.some((p) => existsSync(p)); + const { configured, model } = installed + ? probe.detect(home) + : { configured: false, model: undefined }; + return { + id: probe.id, + label: probe.label, + installed, + configured, + model, + paths, + origin: "local", + }; + }); +} + +/** A single displayed config field for an agent's detail view. */ +export interface AgentField { + label: string; + value: string; + /** Sensitive value: the server masks it before returning. */ + secret?: boolean; + /** Unmasked value for secrets, revealed on demand (localhost only). */ + raw?: string; +} + +/** Raw content of one of the agent's config files. */ +export interface AgentSettingsFile { + path: string; + lang: string; + text: string; +} + +/** Full detail for one agent: extracted config fields plus its config files. */ +export interface AgentDetail extends AgentInfo { + fields: AgentField[]; + files: Array<{ path: string; exists: boolean }>; + settings: AgentSettingsFile[]; +} + +function pushField(out: AgentField[], label: string, value: unknown, secret = false): void { + if (typeof value === "string" && value.trim()) out.push({ label, value: value.trim(), secret }); +} + +/** + * Extract the bailian-cli connection fields (model, base URL, credential, ...) + * an agent has written into its local config. Values are raw here; secrets are + * masked by `getAgentDetail` before leaving the server. Read-only. + */ +function agentDetailFields(id: string, home: string): AgentField[] { + const out: AgentField[] = []; + if (id === "claude-code") { + const env = asRecord(readJsonSafe(join(home, ".claude", "settings.json"))?.env); + pushField(out, "Model", env?.ANTHROPIC_MODEL); + pushField(out, "Base URL", env?.ANTHROPIC_BASE_URL); + pushField(out, "Auth Token", env?.ANTHROPIC_AUTH_TOKEN, true); + } else if (id === "qwen-code") { + const s = readJsonSafe(join(home, ".qwen", "settings.json")); + const model = asRecord(s?.model); + const auth = asRecord(asRecord(s?.security)?.auth); + const env = asRecord(s?.env); + pushField(out, "Model", model?.name); + pushField(out, "Base URL", auth?.baseUrl ?? model?.baseUrl); + pushField(out, "Protocol", auth?.selectedType); + pushField(out, "API Key", auth?.apiKey ?? env?.BAILIAN_CLI_API_KEY, true); + } else if (id === "opencode") { + const provider = asRecord( + readJsonSafe(join(home, ".config", "opencode", "opencode.json"))?.provider, + ); + const bailian = asRecord(provider?.["bailian-cli"]); + const options = asRecord(bailian?.options); + const models = asRecord(bailian?.models); + pushField(out, "Model", models ? Object.keys(models)[0] : undefined); + pushField(out, "Base URL", options?.baseURL); + pushField(out, "Provider", bailian?.npm); + pushField(out, "API Key", options?.apiKey, true); + } else if (id === "openclaw") { + const models = asRecord(readJsonSafe(join(home, ".openclaw", "openclaw.json"))?.models); + const bailian = asRecord(asRecord(models?.providers)?.["bailian-cli"]); + const list = Array.isArray(bailian?.models) ? bailian.models : []; + const first = asRecord(list[0]); + pushField(out, "Model", first?.id); + pushField(out, "Base URL", bailian?.baseUrl); + pushField(out, "API", bailian?.api); + pushField(out, "API Key", bailian?.apiKey, true); + } else if (id === "hermes") { + const text = readText(join(home, ".hermes", "config.yaml")); + let config: Record | undefined; + if (text) { + try { + config = asRecord(yaml.parse(text)); + } catch { + config = undefined; + } + } + const providers = Array.isArray(config?.custom_providers) ? config.custom_providers : []; + const pr = asRecord(providers.find((e: unknown) => asRecord(e)?.name === "bailian-cli")); + const list = Array.isArray(pr?.models) ? pr.models : []; + const first = asRecord(list[0]); + pushField(out, "Model", first?.id ?? asRecord(config?.model)?.default); + pushField(out, "Base URL", pr?.base_url); + pushField(out, "API Mode", pr?.api_mode); + pushField(out, "API Key", pr?.api_key, true); + } else if (id === "codex") { + const text = readText(join(home, ".codex", "config.toml")); + let config: Record = {}; + if (text) { + try { + config = parseToml(text) as Record; + } catch { + config = {}; + } + } + const bailian = asRecord(asRecord(config.model_providers)?.["bailian-cli"]); + const auth = readJsonSafe(join(home, ".codex", "auth.json")); + pushField(out, "Model", config.model); + pushField(out, "Base URL", bailian?.base_url); + pushField(out, "Wire API", bailian?.wire_api); + pushField(out, "API Key", auth?.OPENAI_API_KEY, true); + } + return out; +} + +/** Mask a secret so its presence is visible but the value is not disclosed. */ +function maskSecret(value: string): string { + const v = value.trim(); + if (v.length <= 8) return "\u2022".repeat(Math.max(v.length, 4)); + return v.slice(0, 3) + "\u2022".repeat(Math.min(v.length - 3, 16)); +} + +/** Config keys whose string value is treated as a secret and masked. */ +const SECRET_KEY_RE = /key|token|secret|password|passwd|auth|credential/i; + +/** + * Deep-clone a config entry, masking any string value whose key name looks + * like a secret (API keys, tokens, Authorization headers, ...). Empty strings + * are left untouched so placeholders like "AMAP_MAPS_API_KEY": "" stay visible. + */ +function maskConfigSecrets(value: unknown, keyName = ""): unknown { + if (typeof value === "string") { + return keyName && SECRET_KEY_RE.test(keyName) && value.trim() ? maskSecret(value) : value; + } + if (Array.isArray(value)) return value.map((v) => maskConfigSecrets(v)); + const rec = asRecord(value); + if (rec) { + const out: Record = {}; + for (const [k, v] of Object.entries(rec)) out[k] = maskConfigSecrets(v, k); + return out; + } + return value; +} + +/** Guess a syntax-highlight language for a config file from its extension. */ +function langForPath(p: string): string { + if (p.endsWith(".json")) return "json"; + if (p.endsWith(".yaml") || p.endsWith(".yml")) return "yaml"; + if (p.endsWith(".toml")) return "toml"; + return "text"; +} + +/** + * Full detail for one coding agent by id: normalized config fields plus its + * config file list and raw file contents. Secret values (API keys/tokens) are + * masked in `fields` but their unmasked form is included as `raw` for on-demand + * reveal; raw file contents are returned verbatim. Localhost-only. Returns null + * for an unknown id. + */ +export function getAgentDetail(id: string, home: string = homedir()): AgentDetail | null { + const probe = AGENT_PROBES.find((p) => p.id === id); + if (!probe) return null; + const paths = probe.paths(home); + const installed = paths.some((p) => existsSync(p)); + const { configured, model } = installed + ? probe.detect(home) + : { configured: false, model: undefined }; + const fields = installed + ? agentDetailFields(id, home).map((f) => + f.secret ? { ...f, raw: f.value, value: maskSecret(f.value) } : f, + ) + : []; + const files = paths.map((p) => ({ path: p, exists: existsSync(p) })); + const settings: AgentSettingsFile[] = installed + ? paths + .filter((p) => existsSync(p)) + .map((p) => ({ + path: p, + lang: langForPath(p), + text: readText(p) ?? "", + })) + .filter((s) => s.text.trim()) + : []; + return { + id: probe.id, + label: probe.label, + installed, + configured, + model, + paths, + origin: "local", + fields, + files, + settings, + }; +} diff --git a/packages/commands/src/commands/config/qr.ts b/packages/commands/src/commands/config/qr.ts new file mode 100644 index 0000000..10fe90f --- /dev/null +++ b/packages/commands/src/commands/config/qr.ts @@ -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:/?token=` 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 += ``; + } + } + return ( + `` + + `` + + `${rects}` + ); +} diff --git a/packages/commands/src/commands/config/scenarios.ts b/packages/commands/src/commands/config/scenarios.ts new file mode 100644 index 0000000..c458690 --- /dev/null +++ b/packages/commands/src/commands/config/scenarios.ts @@ -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 { + return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { + const v = values[key]; + return typeof v === "string" ? v.trim() : ""; + }); +} diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 4757140..66e5fd6 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -32,6 +32,79 @@ export const SECRET_KEYS = new Set([ "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 = { + 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(["telemetry"]); + +// Default model each `default_*_model` key falls back to when left unset. These +// mirror the inline `|| ""` 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 = { + 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 = { + 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 = { "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, + ); +} diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 1bcbf8c..6f9a04a 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -1,105 +1,1134 @@ -// Self-contained single-page web UI for managing config profiles. Served as a -// string by `config ui`; no build step, no client dependencies. All fetches -// carry the session token from the page URL. +// Self-contained single-page web UI for managing config profiles and viewing +// locally installed agent tooling (skills, MCP servers, coding agents). Served +// as a string by `config ui`; no build step, no client dependencies. All +// fetches carry the session token from the page URL. Visual language mirrors +// the bailian landing design system (Inter / Geist Mono, gradient accents, +// lift-on-hover cards). export const PAGE_HTML = ` -bailian-cli config +Config - Alibaba Cloud Model Studio CLI + + +
+ -
-
-

- -
-
-
- - - -
+
+
+
+

Quick Start

+

Complete a few steps to check your local environment and finish first-time setup: sign in to Bailian, connect a local coding agent, and run your first task. Each circle turns from gray to green as you go.

+
+
Loading…
+
+
+
+

Playground

+

Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where bl config ui was started.

+
+
+
+
Loading…
+
+
+ +
+
+

Config

+

Credentials and default models. The active profile (marked with a star) is used by every bl command. Click a profile to edit its settings.

+
+
+
+ +
+
+

Installed Skills

+

Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via npx skills add.

+
+
+
Loading…
+
+
+ +
+
+

MCPs

+

Model Context Protocol servers declared in your local coding-agent configs.

+
+
+
Loading…
+
+
+ +
+
+

Coding Agents

+

Frameworks bl can configure. "Connected" means the bailian-cli provider is wired into that agent.

+
+
+
+
Loading…
+
+
+ +
+
+

Generated Assets

+

Media that bl writes into the output directory, grouped by type (images, videos, audio, files) and generation time.

+
+
+
+
Loading…
+
+
+ + + + + +
diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index 2c10c68..5a12de0 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -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 { 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 { }); } +/** Max size for binary uploads (skill .zip packages). */ +const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB + +function readBodyBuffer(req: http.IncomingMessage, max: number): Promise { + 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): Record { - const cleaned: Record = {}; +function buildProfilePatch( + data: Record, +): Record { + const cleaned: Record = {}; 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): Record, - managedPatch: Record, + managedPatch: Record, ): Record { - const managedKeys = new Set(VALID_KEYS); + const managedKeys = new Set(UI_VALID_KEYS); const merged: Record = {}; 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 = {}; + if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) { + for (const [k, v] of Object.entries(body.values as Record)) { + 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; + let cleaned: Record; try { normalized = normalizeConfigName(body.name); cleaned = buildProfilePatch(body.data as Record); @@ -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 { diff --git a/packages/commands/src/commands/dataset/delete.ts b/packages/commands/src/commands/dataset/delete.ts index e8e5427..5e18766 100644 --- a/packages/commands/src/commands/dataset/delete.ts +++ b/packages/commands/src/commands/dataset/delete.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DELETE_FLAGS = { fileId: { @@ -30,6 +30,7 @@ export default defineCommand({ if (settings.quiet || format === "text") { emitBare(`Deleted ${fileId}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/dataset/get.ts b/packages/commands/src/commands/dataset/get.ts index f64080d..2c1cf71 100644 --- a/packages/commands/src/commands/dataset/get.ts +++ b/packages/commands/src/commands/dataset/get.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const GET_FLAGS = { fileId: { @@ -46,7 +46,7 @@ export default defineCommand({ }; if (format === "json") { - emitResult(item, format); + emitResult({ ...item, request_id: response.request_id }, format); return; } @@ -58,5 +58,6 @@ export default defineCommand({ if (item.purpose) emitBare(`purpose: ${item.purpose}`); if (item.created_at) emitBare(`created_at: ${item.created_at}`); if (item.description) emitBare(`description: ${item.description}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/dataset/list.ts b/packages/commands/src/commands/dataset/list.ts index a34e64e..7bc858b 100644 --- a/packages/commands/src/commands/dataset/list.ts +++ b/packages/commands/src/commands/dataset/list.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const LIST_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -55,7 +55,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -68,5 +68,6 @@ export default defineCommand({ const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]); for (const line of formatTable(headers, rows)) emitBare(line); if (total !== undefined) emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/dataset/upload.ts b/packages/commands/src/commands/dataset/upload.ts index f7d805b..a3c6104 100644 --- a/packages/commands/src/commands/dataset/upload.ts +++ b/packages/commands/src/commands/dataset/upload.ts @@ -9,10 +9,9 @@ import { MAX_MEDIA_ZIP_BYTES, BailianError, ExitCode, - type DatasetFile, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const UPLOAD_FLAGS = { file: { @@ -135,17 +134,19 @@ export default defineCommand({ return; } - const uploaded: DatasetFile = await uploadDataset(ctx.client, { + const uploaded = await uploadDataset(ctx.client, { filePath, purpose, }); + const { request_id, ...file } = uploaded; if (settings.quiet) { - emitBare(uploaded.file_id); + emitBare(file.file_id); } else if (format === "text") { - emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`); + emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`); + emitRequestId(request_id, settings.quiet); } else { - emitResult(uploaded, format); + emitResult({ ...file, request_id }, format); } }, }); diff --git a/packages/commands/src/commands/deploy/create.ts b/packages/commands/src/commands/deploy/create.ts index 60e1857..96c75d9 100644 --- a/packages/commands/src/commands/deploy/create.ts +++ b/packages/commands/src/commands/deploy/create.ts @@ -11,7 +11,7 @@ import { type CommandContext, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const CREATE_FLAGS = { model: { @@ -163,6 +163,7 @@ async function runCreate( emitBare( `\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? ""}`, ); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/deploy/delete.ts b/packages/commands/src/commands/deploy/delete.ts index 8340d09..bd6dd85 100644 --- a/packages/commands/src/commands/deploy/delete.ts +++ b/packages/commands/src/commands/deploy/delete.ts @@ -7,7 +7,7 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DELETE_FLAGS = { deployedModel: { @@ -71,6 +71,7 @@ export default defineCommand({ emitBare(deployedModel); } else if (format === "text") { emitBare(`Deleted ${deployedModel}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/deploy/get.ts b/packages/commands/src/commands/deploy/get.ts index e8f6040..9c53e96 100644 --- a/packages/commands/src/commands/deploy/get.ts +++ b/packages/commands/src/commands/deploy/get.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const GET_FLAGS = { deployedModel: { @@ -58,7 +58,7 @@ export default defineCommand({ if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified; if (format === "json") { - emitResult(item, format); + emitResult({ ...item, request_id: response.request_id }, format); return; } @@ -69,5 +69,6 @@ export default defineCommand({ const display = typeof value === "string" ? value : JSON.stringify(value); emitBare(`${label(key)}${display}`); } + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/deploy/list.ts b/packages/commands/src/commands/deploy/list.ts index d26b4e3..e7c2917 100644 --- a/packages/commands/src/commands/deploy/list.ts +++ b/packages/commands/src/commands/deploy/list.ts @@ -4,7 +4,7 @@ import { listDeployments, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const LIST_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -58,7 +58,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -78,5 +78,6 @@ export default defineCommand({ ]); for (const line of formatTable(headers, rows)) emitBare(line); if (total !== undefined) emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/deploy/models.ts b/packages/commands/src/commands/deploy/models.ts index 6bc8daa..d1ea972 100644 --- a/packages/commands/src/commands/deploy/models.ts +++ b/packages/commands/src/commands/deploy/models.ts @@ -4,7 +4,7 @@ import { listDeployableModels, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const MODELS_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -122,7 +122,7 @@ export default defineCommand({ } return out; }); - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -168,5 +168,6 @@ export default defineCommand({ ]); for (const line of formatTable(headers, rows)) emitBare(line); if (total !== undefined) emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/deploy/scale.ts b/packages/commands/src/commands/deploy/scale.ts index d6d731b..805f9c5 100644 --- a/packages/commands/src/commands/deploy/scale.ts +++ b/packages/commands/src/commands/deploy/scale.ts @@ -4,7 +4,7 @@ import { scaleDeployment, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const SCALE_FLAGS = { deployedModel: { @@ -72,6 +72,7 @@ export default defineCommand({ } else if (format === "text") { const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : ""; emitBare(`Scaled ${deployedModel}${cap}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/deploy/update.ts b/packages/commands/src/commands/deploy/update.ts index fd582c9..0bbb29d 100644 --- a/packages/commands/src/commands/deploy/update.ts +++ b/packages/commands/src/commands/deploy/update.ts @@ -4,7 +4,7 @@ import { updateDeployment, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const UPDATE_FLAGS = { deployedModel: { @@ -70,6 +70,7 @@ export default defineCommand({ if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`); const summary = parts.length ? ` (${parts.join(", ")})` : ""; emitBare(`Updated ${deployedModel}${summary}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/cancel.ts b/packages/commands/src/commands/finetune/cancel.ts index bc511ad..789a3b0 100644 --- a/packages/commands/src/commands/finetune/cancel.ts +++ b/packages/commands/src/commands/finetune/cancel.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const CANCEL_FLAGS = { jobId: { @@ -38,6 +38,7 @@ export default defineCommand({ } else if (format === "text") { const status = job?.status ? ` (status=${job.status})` : ""; emitBare(`Cancelled ${jobId}${status}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/checkpoints.ts b/packages/commands/src/commands/finetune/checkpoints.ts index ca11000..7c05a36 100644 --- a/packages/commands/src/commands/finetune/checkpoints.ts +++ b/packages/commands/src/commands/finetune/checkpoints.ts @@ -4,7 +4,7 @@ import { listCheckpoints, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const CHECKPOINTS_FLAGS = { jobId: { @@ -47,7 +47,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -60,5 +60,6 @@ export default defineCommand({ const rows = items.map((i) => [i.checkpoint, i.step, i.status]); for (const line of formatTable(headers, rows)) emitBare(line); emitBare(`\nTotal: ${total}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/finetune/create.ts b/packages/commands/src/commands/finetune/create.ts index 8e72375..6fbb54f 100644 --- a/packages/commands/src/commands/finetune/create.ts +++ b/packages/commands/src/commands/finetune/create.ts @@ -27,7 +27,7 @@ import { } from "bailian-cli-core"; import { existsSync, statSync } from "fs"; import { basename } from "path"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; /** * A `--datasets` / `--validations` token is treated as a local file to upload @@ -631,6 +631,7 @@ async function runCreate( if (job?.job_id) { emitBare(`Created fine-tune job: ${job.job_id}`); if (job.status) emitBare(`Status: ${job.status}`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/delete.ts b/packages/commands/src/commands/finetune/delete.ts index f5ab6fc..102fda6 100644 --- a/packages/commands/src/commands/finetune/delete.ts +++ b/packages/commands/src/commands/finetune/delete.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DELETE_FLAGS = { jobId: { @@ -36,6 +36,7 @@ export default defineCommand({ emitBare(jobId); } else if (format === "text") { emitBare(`Deleted ${jobId}.`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/export.ts b/packages/commands/src/commands/finetune/export.ts index 7f3857e..7e52de0 100644 --- a/packages/commands/src/commands/finetune/export.ts +++ b/packages/commands/src/commands/finetune/export.ts @@ -4,7 +4,7 @@ import { exportCheckpoint, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const EXPORT_FLAGS = { jobId: { @@ -69,6 +69,7 @@ export default defineCommand({ emitBare( `Next: ${identity.binName} deploy text create --model ${exported} --name `, ); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/get.ts b/packages/commands/src/commands/finetune/get.ts index d661c79..0ba0114 100644 --- a/packages/commands/src/commands/finetune/get.ts +++ b/packages/commands/src/commands/finetune/get.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const GET_FLAGS = { jobId: { @@ -56,7 +56,7 @@ export default defineCommand({ }; if (format === "json") { - emitResult(item, format); + emitResult({ ...item, request_id: response.request_id }, format); return; } @@ -76,5 +76,6 @@ export default defineCommand({ if (item.model_name) emitBare(`model_name: ${item.model_name}`); if (item.created_at) emitBare(`created_at: ${item.created_at}`); if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/finetune/list.ts b/packages/commands/src/commands/finetune/list.ts index d8d822d..ca4eddd 100644 --- a/packages/commands/src/commands/finetune/list.ts +++ b/packages/commands/src/commands/finetune/list.ts @@ -1,5 +1,5 @@ import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core"; -import { emitResult, emitBare, formatTable } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime"; const LIST_FLAGS = { page: { type: "number", valueHint: "", description: "Page number (default: 1)" }, @@ -48,7 +48,7 @@ export default defineCommand({ })); if (format === "json") { - emitResult({ items, total }, format); + emitResult({ items, total, request_id: response.request_id }, format); return; } @@ -78,5 +78,6 @@ export default defineCommand({ emitBare( `Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``, ); + emitRequestId(response.request_id, settings.quiet); }, }); diff --git a/packages/commands/src/commands/finetune/logs.ts b/packages/commands/src/commands/finetune/logs.ts index 322327b..bd0802e 100644 --- a/packages/commands/src/commands/finetune/logs.ts +++ b/packages/commands/src/commands/finetune/logs.ts @@ -6,7 +6,7 @@ import { type FineTuneLogEntry, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; /** * Render a single log entry as a single line (mirrors the flatten logic used @@ -187,6 +187,7 @@ export default defineCommand({ emitBare(renderEntry(entry)); } if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`); + emitRequestId(response.request_id, settings.quiet); } else { emitResult(response, format); } diff --git a/packages/commands/src/commands/finetune/watch.ts b/packages/commands/src/commands/finetune/watch.ts index 1eb589a..c02b207 100644 --- a/packages/commands/src/commands/finetune/watch.ts +++ b/packages/commands/src/commands/finetune/watch.ts @@ -6,7 +6,7 @@ import { ExitCode, type FlagsDef, } from "bailian-cli-core"; -import { emitResult, emitBare } from "bailian-cli-runtime"; +import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime"; const DEFAULT_INTERVAL_SEC = 10; const MIN_INTERVAL_SEC = 1; @@ -135,9 +135,13 @@ export default defineCommand({ } else if (format === "text") { emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`); if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`); + emitRequestId(response.request_id, settings.quiet); } else { // json: a compact, purpose-built status probe. - emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format); + emitResult( + { job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id }, + format, + ); } if (terminal && status !== "SUCCEEDED") { @@ -175,6 +179,7 @@ export default defineCommand({ emitResult(response, format); } else if (status === "SUCCEEDED") { emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`); + emitRequestId(response.request_id, settings.quiet); } if (status !== "SUCCEEDED") { throw new BailianError( diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index a6996e4..95d5054 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -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: "", description: "Negative prompt to exclude unwanted content", }, + function: { + type: "string", + valueHint: "", + description: + "wanx*-imageedit function (default: description_edit). Examples: stylization_all, description_edit", + }, promptExtend: { type: "boolean", valueHint: "", @@ -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 = { + 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); 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 { const results = await runConcurrent(concurrent, settings, () => client.requestJson({ - 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({ - 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`) }]; diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 00bd7f5..31eaab5 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -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: "", 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 = { + 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 { const results = await runConcurrent(concurrent, settings, () => - client.requestJson({ path: imageSyncPath(), method: "POST", body }), + client.requestJson({ 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({ - 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`) }]; diff --git a/packages/commands/src/commands/managed-agent/init.ts b/packages/commands/src/commands/managed-agent/init.ts index 6fd3100..fd50d2b 100644 --- a/packages/commands/src/commands/managed-agent/init.ts +++ b/packages/commands/src/commands/managed-agent/init.ts @@ -25,7 +25,7 @@ const PROVIDER_BLOCKS: Record = { }; const SINGLE_MODEL: Record = { - bailian: ` model: qwen3.7-max`, + bailian: ` model: qwen3.8-max`, claude: ` model: claude-sonnet-4-6`, qoder: ` model: ultimate`, ark: ` model: doubao-seed-2-1-pro-260628`, @@ -39,7 +39,7 @@ function buildTemplate(options: { provider: string; agentName: string }): string const modelBlock = options.provider === "all" - ? ` model:\n bailian: qwen3.7-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628` + ? ` model:\n bailian: qwen3.8-max\n claude: claude-sonnet-4-6\n qoder: ultimate\n ark: doubao-seed-2-1-pro-260628` : SINGLE_MODEL[options.provider]!; const toolBlock = diff --git a/packages/commands/src/commands/mcp/activate-hint.ts b/packages/commands/src/commands/mcp/activate-hint.ts new file mode 100644 index 0000000..ff7e11c --- /dev/null +++ b/packages/commands/src/commands/mcp/activate-hint.ts @@ -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; +} diff --git a/packages/commands/src/commands/mcp/call.ts b/packages/commands/src/commands/mcp/call.ts index b0517cd..3d6ba0b 100644 --- a/packages/commands/src/commands/mcp/call.ts +++ b/packages/commands/src/commands/mcp/call.ts @@ -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); }, }); diff --git a/packages/commands/src/commands/mcp/tools.ts b/packages/commands/src/commands/mcp/tools.ts index bc69128..fc38f42 100644 --- a/packages/commands/src/commands/mcp/tools.ts +++ b/packages/commands/src/commands/mcp/tools.ts @@ -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; + } }, }); diff --git a/packages/commands/src/commands/search/web-activate-hint.ts b/packages/commands/src/commands/search/web-activate-hint.ts new file mode 100644 index 0000000..cbf6c7e --- /dev/null +++ b/packages/commands/src/commands/search/web-activate-hint.ts @@ -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"); +} diff --git a/packages/commands/src/commands/search/web.ts b/packages/commands/src/commands/search/web.ts index 9804527..b294ea2 100644 --- a/packages/commands/src/commands/search/web.ts +++ b/packages/commands/src/commands/search/web.ts @@ -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: "", 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); } }, }); diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts index ffbf5c1..102247d 100644 --- a/packages/commands/src/commands/shared/local-server.ts +++ b/packages/commands/src/commands/shared/local-server.ts @@ -22,11 +22,15 @@ export function listenLocalServer(server: http.Server, port = 0): Promise { +/** + * 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 { 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 { }); }); } + +/** Open a URL in the user's default browser (best-effort, cross-platform). */ +export function openInBrowser(url: string): Promise { + return openPath(url); +} diff --git a/packages/commands/src/commands/skill/add.ts b/packages/commands/src/commands/skill/add.ts new file mode 100644 index 0000000..5cb6f97 --- /dev/null +++ b/packages/commands/src/commands/skill/add.ts @@ -0,0 +1,110 @@ +import { + BailianError, + ExitCode, + defineCommand, + detectOutputFormat, + detectInstalledAgents, + fetchSkillsIndex, + getSkillRegistryBaseUrl, + installSkillWithFanout, + parseSkillNames, + readSkillLock, + runWithConcurrency, + writeSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +interface AddOutcome { + name: string; + status: "installed" | "failed"; + publishedAt?: string; + agents?: string[]; + reason?: string; +} + +/** Max number of skills downloading/installing at the same time. */ +const INSTALL_CONCURRENCY = 3; + +export default defineCommand({ + description: "Install skills from the Bailian skill registry into local agents", + auth: "none", + usageArgs: "--name ", + flags: { + name: { + type: "string", + valueHint: "", + description: "Skills to install: all or comma-separated skill names", + required: true, + }, + }, + exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const requested = parseSkillNames(ctx.flags.name, false); + const index = await fetchSkillsIndex(); + const remoteNames = Object.keys(index.skills); + const names = requested === "all" ? remoteNames : requested; + + const lock = readSkillLock(); + const agents = detectInstalledAgents(); + + // collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual. + // Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key. + const tasks = names.map((name) => async (): Promise => { + const entry = index.skills[name]; + if (!entry) { + return { name, status: "failed", reason: "skill not found in registry" }; + } + try { + const record = await installSkillWithFanout(name, entry, agents); + lock.skills[name] = record.lockEntry; + return { + name, + status: "installed", + publishedAt: entry.publishedAt, + agents: record.linkedAgents, + }; + } catch (err) { + return { + name, + status: "failed", + reason: err instanceof Error ? err.message : String(err), + }; + } + }); + const results = await runWithConcurrency(tasks, INSTALL_CONCURRENCY); + writeSkillLock(lock); + + if (format === "json") { + emitResult( + { + registry: getSkillRegistryBaseUrl(), + agents: agents.map((agent) => agent.id), + skills: results, + }, + format, + ); + } else if (results.length === 0) { + emitBare("Skill registry is empty; no skills to install."); + } else { + const rows = results.map((result) => [ + result.name, + result.status, + result.publishedAt ? result.publishedAt.slice(0, 10) : "-", + result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"), + ]); + for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) { + emitBare(line); + } + } + + const failed = results.filter((result) => result.status === "failed"); + if (failed.length > 0) { + throw new BailianError( + `${failed.length}/${results.length} skill(s) failed to install`, + ExitCode.GENERAL, + "Check the reason for failed skills in the output; network failures can be retried with bl skill add", + ); + } + }, +}); diff --git a/packages/commands/src/commands/skill/list.ts b/packages/commands/src/commands/skill/list.ts new file mode 100644 index 0000000..aa6552c --- /dev/null +++ b/packages/commands/src/commands/skill/list.ts @@ -0,0 +1,58 @@ +import { + defineCommand, + detectOutputFormat, + computeSkillStatuses, + fetchSkillsIndex, + getSkillRegistryBaseUrl, + listSkillDirsOnDisk, + readSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +const DESCRIPTION_MAX = 60; + +function truncate(text: string | undefined): string { + if (!text) return "-"; + return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1)}…` : text; +} + +export default defineCommand({ + description: "List registry skills and diff against local installs", + auth: "none", + exampleArgs: ["", "--output json"], + notes: [ + "STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)", + ], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + // Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk + const index = await fetchSkillsIndex(); + const lock = readSkillLock(); + const rows = computeSkillStatuses(index, lock, listSkillDirsOnDisk()); + + if (format === "json") { + emitResult( + { + registry: getSkillRegistryBaseUrl(), + ...(index.updatedAt ? { updatedAt: index.updatedAt } : {}), + skills: rows, + }, + format, + ); + return; + } + if (rows.length === 0) { + emitBare("Skill registry is empty and no skills are installed locally."); + return; + } + const table = rows.map((row) => [ + row.name, + row.status, + row.publishedAt ? row.publishedAt.slice(0, 19).replace("T", " ") : "-", + truncate(row.description), + ]); + for (const line of formatTable(["NAME", "STATUS", "UPDATEDAT", "DESCRIPTION"], table)) { + emitBare(line); + } + }, +}); diff --git a/packages/commands/src/commands/skill/remove.ts b/packages/commands/src/commands/skill/remove.ts new file mode 100644 index 0000000..e51106c --- /dev/null +++ b/packages/commands/src/commands/skill/remove.ts @@ -0,0 +1,100 @@ +import { + BailianError, + ExitCode, + defineCommand, + detectOutputFormat, + listSkillDirsOnDisk, + parseSkillNames, + readSkillLock, + removeSkillDir, + unlinkSkillFromAgents, + writeSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +interface RemoveOutcome { + name: string; + status: "removed" | "failed"; + removedLinks?: number; + reason?: string; +} + +export default defineCommand({ + description: "Remove locally installed skills (registry is untouched)", + auth: "none", + usageArgs: "--name ", + flags: { + name: { + type: "string", + valueHint: "", + description: "Skills to remove: all or comma-separated skill names", + required: true, + }, + }, + exampleArgs: ["--name spark-video", "--name all"], + async run(ctx) { + // Purely local operation: no remote access, works offline + const format = detectOutputFormat(ctx.settings.output); + const requested = parseSkillNames(ctx.flags.name, false); + const lock = readSkillLock(); + const names = requested === "all" ? Object.keys(lock.skills) : requested; + + if (names.length === 0) { + emitResult({ skills: [] }, format); + if (format === "text") emitBare("No skills installed locally; nothing to remove."); + return; + } + + const diskDirs = new Set(listSkillDirsOnDisk()); + const results: RemoveOutcome[] = []; + for (const name of names) { + const locked = lock.skills[name]; + if (!locked) { + results.push({ + name, + status: "failed", + reason: diskDirs.has(name) + ? "directory not managed by bl skill (untracked); remove manually if needed" + : "not installed", + }); + continue; + } + try { + // Reclaim agent fan-out first, then delete canonical, finally clear the lock entry + const removedLinks = unlinkSkillFromAgents(name, locked.links ?? []); + removeSkillDir(name); + delete lock.skills[name]; + results.push({ name, status: "removed", removedLinks: removedLinks.length }); + } catch (err) { + results.push({ + name, + status: "failed", + reason: err instanceof Error ? err.message : String(err), + }); + } + } + writeSkillLock(lock); + + if (format === "json") { + emitResult({ skills: results }, format); + } else { + const rows = results.map((r) => [ + r.name, + r.status, + r.status === "removed" ? `reclaimed ${r.removedLinks} agent link(s)` : (r.reason ?? "-"), + ]); + for (const line of formatTable(["NAME", "STATUS", "DETAIL"], rows)) { + emitBare(line); + } + } + + const failed = results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + throw new BailianError( + `${failed.length}/${results.length} skill(s) failed to remove`, + ExitCode.GENERAL, + "Check the reason for failed skills in the output; use bl skill list to verify local install status", + ); + } + }, +}); diff --git a/packages/commands/src/commands/skill/update.ts b/packages/commands/src/commands/skill/update.ts new file mode 100644 index 0000000..127e641 --- /dev/null +++ b/packages/commands/src/commands/skill/update.ts @@ -0,0 +1,140 @@ +import { + BailianError, + ExitCode, + defineCommand, + detectOutputFormat, + detectInstalledAgents, + fetchSkillsIndex, + getSkillRegistryBaseUrl, + installSkillWithFanout, + listSkillDirsOnDisk, + parseSkillNames, + readSkillLock, + runWithConcurrency, + writeSkillLock, +} from "bailian-cli-core"; +import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; + +interface UpdateOutcome { + name: string; + status: "updated" | "up-to-date" | "skipped" | "failed"; + publishedAt?: string; + reason?: string; +} + +/** Max number of skills downloading/installing at the same time. */ +const UPDATE_CONCURRENCY = 3; + +export default defineCommand({ + description: "Update installed skills to the latest registry versions", + auth: "none", + usageArgs: "[--name ]", + flags: { + name: { + type: "string", + valueHint: "", + description: + "Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills)", + }, + }, + exampleArgs: ["", "--name spark-video"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + const requested = parseSkillNames(ctx.flags.name, true); + const index = await fetchSkillsIndex(); + const lock = readSkillLock(); + const disk = new Set(listSkillDirsOnDisk()); + + const results: UpdateOutcome[] = []; + const targets: string[] = []; + if (requested === "all") { + // Default: only process skills already installed in lock; reinstall only if version changed or local dir is missing + for (const [name, locked] of Object.entries(lock.skills)) { + const entry = index.skills[name]; + if (!entry) { + results.push({ + name, + status: "skipped", + reason: "delisted from remote; local copy retained", + }); + continue; + } + if (entry.contentHash === locked.contentHash && disk.has(name)) { + results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt }); + continue; + } + targets.push(name); + } + } else { + // Explicit names: only update skills that are already installed; reject uninstalled ones + for (const name of requested) { + if (!lock.skills[name]) { + results.push({ + name, + status: "failed", + reason: "not installed; run bl skill add --name " + name + " first", + }); + continue; + } + targets.push(name); + } + } + + const agents = detectInstalledAgents(); + const tasks = targets.map((name) => async (): Promise => { + const entry = index.skills[name]; + if (!entry) { + return { name, status: "failed", reason: "skill not found in registry" }; + } + try { + const record = await installSkillWithFanout(name, entry, agents); + lock.skills[name] = record.lockEntry; + return { name, status: "updated", publishedAt: entry.publishedAt }; + } catch (err) { + return { + name, + status: "failed", + reason: err instanceof Error ? err.message : String(err), + }; + } + }); + const updateResults = await runWithConcurrency(tasks, UPDATE_CONCURRENCY); + results.push(...updateResults); + writeSkillLock(lock); + + if (format === "json") { + emitResult({ registry: getSkillRegistryBaseUrl(), skills: results }, format); + } else if (results.length === 0) { + emitBare("No skills installed locally; run bl skill add first."); + } else { + const rows = results.map((result) => [ + result.name, + result.status, + result.publishedAt ? result.publishedAt.slice(0, 10) : "-", + ]); + for (const line of formatTable(["NAME", "STATUS", "PUBLISHED"], rows)) { + emitBare(line); + } + + // Footnotes for skipped / failed entries + const annotated = results.filter( + (result) => (result.status === "skipped" || result.status === "failed") && result.reason, + ); + if (annotated.length > 0) { + emitBare(""); + for (const result of annotated) { + emitBare(` ${result.name}: ${result.reason}`); + } + } + } + + const failed = results.filter((result) => result.status === "failed"); + if (failed.length > 0) { + throw new BailianError( + `${failed.length} skill(s) failed to update`, + ExitCode.GENERAL, + "Check the reason for failed skills in the output; network failures can be retried with bl skill update", + ); + } + }, +}); diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 0ab05a3..0beb55d 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -15,7 +15,7 @@ import { ansi, emitResult, emitBare } from "bailian-cli-runtime"; import { readFileSync } from "fs"; const CHAT_FLAGS = { - model: { type: "string", valueHint: "", description: "Model ID (default: qwen3.7-max)" }, + model: { type: "string", valueHint: "", description: "Model ID (default: qwen3.8-max)" }, message: { type: "array", valueHint: "", @@ -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) { diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index ab112c3..cba8b32 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -113,3 +113,7 @@ export { default as pluginInstall } from "./commands/plugin/install.ts"; export { default as pluginLink } from "./commands/plugin/link.ts"; export { default as pluginList } from "./commands/plugin/list.ts"; export { default as pluginRemove } from "./commands/plugin/remove.ts"; +export { default as skillAdd } from "./commands/skill/add.ts"; +export { default as skillUpdate } from "./commands/skill/update.ts"; +export { default as skillRemove } from "./commands/skill/remove.ts"; +export { default as skillList } from "./commands/skill/list.ts"; diff --git a/packages/commands/tests/agent-launch.test.ts b/packages/commands/tests/agent-launch.test.ts new file mode 100644 index 0000000..0a91fe5 --- /dev/null +++ b/packages/commands/tests/agent-launch.test.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); +}); diff --git a/packages/commands/tests/assets.test.ts b/packages/commands/tests/assets.test.ts new file mode 100644 index 0000000..63f02e9 --- /dev/null +++ b/packages/commands/tests/assets.test.ts @@ -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"); +}); diff --git a/packages/commands/tests/auth-console-ui.test.ts b/packages/commands/tests/auth-console-ui.test.ts new file mode 100644 index 0000000..3f74378 --- /dev/null +++ b/packages/commands/tests/auth-console-ui.test.ts @@ -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) { + 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"); +}); diff --git a/packages/commands/tests/config-agent-decode-key.test.ts b/packages/commands/tests/config-agent-decode-key.test.ts new file mode 100644 index 0000000..71027f0 --- /dev/null +++ b/packages/commands/tests/config-agent-decode-key.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "vite-plus/test"; +import { decodeTokenPlanKey } from "../src/commands/config/agent/decode-key.ts"; + +/** + * decode-key 单元测试:在测试内移植前端 encodeTokenPlanKey 参考实现 + * (bailian-tokenplan encode-token-plan-key.ts),做 encode → decode round-trip, + * 保证 CLI 解码与前端编码逐位互逆。 + */ + +const TOKEN_PREFIX = "o1_"; +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_."; +const ALPHABET_SIZE = ALPHABET.length; +const ALPHABET_INDEX = new Map(ALPHABET.split("").map((character, index) => [character, index])); +const CHECKSUM_LENGTH = 6; +const FEISTEL_ROUNDS = 8; + +function toDigits(value: string): number[] { + return value.split("").map((character) => { + const digit = ALPHABET_INDEX.get(character); + if (digit === undefined) throw new Error("bad char"); + return digit; + }); +} + +function fromDigits(digits: number[]): string { + return digits.map((digit) => ALPHABET[digit]).join(""); +} + +function mixState(state: number, value: number): number { + return Math.imul((state ^ value) >>> 0, 0x01000193) >>> 0; +} + +function nextState(state: number): number { + let next = state >>> 0; + next ^= next << 13; + next ^= next >>> 17; + next ^= next << 5; + return next >>> 0; +} + +function createRoundMask(right: number[], salt: string, round: number, length: number): number[] { + let state = (0x811c9dc5 ^ Math.imul(round + 1, 0x9e3779b1)) >>> 0; + state = mixState(state, right.length); + state = mixState(state, length); + for (const character of salt) { + state = mixState(state, (ALPHABET_INDEX.get(character) ?? -1) + 1); + } + for (const digit of right) { + state = mixState(state, digit + 1); + } + state ^= state >>> 16; + state = Math.imul(state, 0x85ebca6b) >>> 0; + state ^= state >>> 13; + state = Math.imul(state, 0xc2b2ae35) >>> 0; + state ^= state >>> 16; + state = state >>> 0 || 0x6d2b79f5; + + const mask: number[] = []; + for (let index = 0; index < length; index += 1) { + state = (state + Math.imul(index + 1, 0x9e3779b1)) >>> 0; + state = nextState(state); + mask.push(state % ALPHABET_SIZE); + } + return mask; +} + +function obfuscatePayload(apiKey: string, salt: string): string { + const digits = toDigits(apiKey); + const midpoint = Math.floor(digits.length / 2); + let left = digits.slice(0, midpoint); + let right = digits.slice(midpoint); + + for (let round = 0; round < FEISTEL_ROUNDS; round += 1) { + const mask = createRoundMask(right, salt, round, left.length); + const nextRight = left.map((digit, index) => (digit + mask[index]) % ALPHABET_SIZE); + left = right; + right = nextRight; + } + return fromDigits([...left, ...right]); +} + +function crc32(value: string): number { + let checksum = 0xffffffff; + for (let index = 0; index < value.length; index += 1) { + checksum ^= value.charCodeAt(index); + for (let bit = 0; bit < 8; bit += 1) { + const mask = -(checksum & 1); + checksum = (checksum >>> 1) ^ (0xedb88320 & mask); + } + } + return (checksum ^ 0xffffffff) >>> 0; +} + +function encodeBase65Number(value: number, length: number): string { + let remaining = value >>> 0; + const encoded = Array(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/); + }); +}); diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts index 1537c20..a08becd 100644 --- a/packages/commands/tests/config-agent-writers.test.ts +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -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 { 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).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; + 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; + 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 }; - 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).BAILIAN_CLI_API_KEY).toBe("sk-q"); - expect((settings.model as Record).name).toBe("qwen3-coder-plus"); + expect(settings.$version).toBe(3); + const security = settings.security as { auth: Record }; + // 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).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>>; + // 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>>) + .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).openai; expect(openaiEntries).toHaveLength(1); + expect((settings.env as Record).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>> + ).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).BAILIAN_TOKEN_PLAN_API_KEY).toBe( + "sk-token-plan", + ); + expect((settings.env as Record).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; + 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>; 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; expect(models.mode).toBe("merge"); const bailian = (models.providers as Record>)["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>)[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 }; + }; 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).providers as Record< + string, + { api: string; models: Array> } + >; + expect(providers2["bailian-cli"].api).toBe("anthropic-messages"); + expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000); expect( - ((config2.models as Record).providers as Record)[ - "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 }; + }; + 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 }).providers["bailian-token-plan"], + ).toBeDefined(); + expect( + (config.models as { providers: Record }).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>).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>).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.", () => { @@ -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/); + }); }); diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index cb35037..a98f8db 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -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/); }); }); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 5db70c1..cfb719b 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -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", diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index a0a48f9..e9b1bdb 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -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 }); } diff --git a/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml b/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml index 2d6c375..61d6041 100644 --- a/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml +++ b/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml @@ -21,7 +21,7 @@ agents: assistant: description: "E2E multi-provider fixture" model: - bailian: qwen3.7-max + bailian: qwen3.8-max claude: claude-sonnet-4-6 instructions: | You are a helpful assistant. diff --git a/packages/commands/tests/e2e/image-edit.e2e.test.ts b/packages/commands/tests/e2e/image-edit.e2e.test.ts index 7476a8d..3b405fa 100644 --- a/packages/commands/tests/e2e/image-edit.e2e.test.ts +++ b/packages/commands/tests/e2e/image-edit.e2e.test.ts @@ -96,6 +96,78 @@ describe("e2e: image edit", () => { "data:image/png;base64,", ); }); + + 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", () => { diff --git a/packages/commands/tests/e2e/image-generate.e2e.test.ts b/packages/commands/tests/e2e/image-generate.e2e.test.ts index 0a5cab6..913567e 100644 --- a/packages/commands/tests/e2e/image-generate.e2e.test.ts +++ b/packages/commands/tests/e2e/image-generate.e2e.test.ts @@ -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())( diff --git a/packages/commands/tests/e2e/skill.e2e.test.ts b/packages/commands/tests/e2e/skill.e2e.test.ts new file mode 100644 index 0000000..d0af753 --- /dev/null +++ b/packages/commands/tests/e2e/skill.e2e.test.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { isBailianE2EEnabled, parseStdoutJson, runCommandE2e } from "./helpers.ts"; +import { SKILL_ROUTES } from "./topic-routes.ts"; + +/** Canonical always-published skill; also the backbone of advisor wiki sync */ +const WIKI_SKILL = "bailian-docs-llm-wiki"; + +/** Redirect ~/.bailian into a throwaway dir so lock/skill writes never touch the real user config */ +function makeTempConfigDir(): string { + return mkdtempSync(join(tmpdir(), "bl-skill-e2e-")); +} + +describe("e2e: skill", () => { + test("skill add --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--name/); + }); + + test("skill update --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--name/); + }); + + test("skill remove --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "remove", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--name/); + }); + + test("skill list --help exits successfully", async () => { + const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "list", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/list|registry/i); + }); +}); + +// Local-only cases: auth "none" + validation happens before any network access, no gating needed +describe("e2e: skill (local, no credentials)", () => { + test("skill add without --name errors as usage error (2)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [ + "skill", + "add", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i); + }); + + test("skill remove without --name errors as usage error (2)", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [ + "skill", + "remove", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i); + }); + + test("skill add rejects mixing all with specific names (2)", async () => { + // parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe + const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [ + "skill", + "add", + "--name", + "all,spark-video", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(`${stdout}\n${stderr}`).toMatch(/all/i); + }); + + test("skill remove of a not-installed skill fails with reason (1)", async () => { + const configDir = makeTempConfigDir(); + const { stdout, exitCode } = await runCommandE2e( + SKILL_ROUTES, + ["skill", "remove", "--name", "definitely-not-installed", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(exitCode).toBe(1); + const data = parseStdoutJson<{ + skills?: Array<{ name?: string; status?: string; reason?: string }>; + }>(stdout); + expect(data.skills?.[0]?.status).toBe("failed"); + expect(data.skills?.[0]?.reason).toMatch(/not installed/i); + }); +}); + +describe.skipIf(!isBailianE2EEnabled())("e2e: skill (real registry)", () => { + test("skill list --output json returns registry and status rows", async () => { + const configDir = makeTempConfigDir(); + const { stdout, stderr, exitCode } = await runCommandE2e( + SKILL_ROUTES, + ["skill", "list", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + registry?: string; + skills?: Array<{ name?: string; status?: string }>; + }>(stdout); + expect(data.registry).toMatch(/^https?:\/\//); + expect(Array.isArray(data.skills)).toBe(true); + }, 60_000); + + test("skill add + remove full lifecycle in isolated dirs", async () => { + const configDir = makeTempConfigDir(); + // Empty fake home → no agents detected → fan-out never leaves the sandbox + const fakeHome = makeTempConfigDir(); + const env = { BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome }; + + const added = await runCommandE2e( + SKILL_ROUTES, + ["skill", "add", "--name", WIKI_SKILL, "--output", "json"], + env, + ); + expect(added.exitCode, added.stderr).toBe(0); + const addData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>( + added.stdout, + ); + expect(addData.skills?.[0]?.status).toBe("installed"); + expect(existsSync(join(configDir, "skills", WIKI_SKILL, "SKILL.md"))).toBe(true); + + const removed = await runCommandE2e( + SKILL_ROUTES, + ["skill", "remove", "--name", WIKI_SKILL, "--output", "json"], + env, + ); + expect(removed.exitCode, removed.stderr).toBe(0); + const removeData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>( + removed.stdout, + ); + expect(removeData.skills?.[0]?.status).toBe("removed"); + expect(existsSync(join(configDir, "skills", WIKI_SKILL))).toBe(false); + }, 300_000); +}); diff --git a/packages/commands/tests/e2e/text-chat.e2e.test.ts b/packages/commands/tests/e2e/text-chat.e2e.test.ts index a5f4d5e..56f1d8f 100644 --- a/packages/commands/tests/e2e/text-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/text-chat.e2e.test.ts @@ -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", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index f010ec9..510f70e 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -156,6 +156,13 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = { "token-plan add-member": "tokenPlanAddMember", }; +export const SKILL_ROUTES: E2eRouteExports = { + "skill add": "skillAdd", + "skill update": "skillUpdate", + "skill remove": "skillRemove", + "skill list": "skillList", +}; + export const MANAGED_AGENT_ROUTES: E2eRouteExports = { "managed-agent init": "managedAgentInit", "managed-agent validate": "managedAgentValidate", diff --git a/packages/commands/tests/e2e/vision-describe.e2e.test.ts b/packages/commands/tests/e2e/vision-describe.e2e.test.ts index a063911..95d7bf9 100644 --- a/packages/commands/tests/e2e/vision-describe.e2e.test.ts +++ b/packages/commands/tests/e2e/vision-describe.e2e.test.ts @@ -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"); }); }); diff --git a/packages/commands/tests/inventory.test.ts b/packages/commands/tests/inventory.test.ts new file mode 100644 index 0000000..66fc573 --- /dev/null +++ b/packages/commands/tests/inventory.test.ts @@ -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] " 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); + }); +}); diff --git a/packages/commands/tests/mcp-activate-hint.test.ts b/packages/commands/tests/mcp-activate-hint.test.ts new file mode 100644 index 0000000..823a6b8 --- /dev/null +++ b/packages/commands/tests/mcp-activate-hint.test.ts @@ -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); + } + }); +}); diff --git a/packages/commands/tests/qr.test.ts b/packages/commands/tests/qr.test.ts new file mode 100644 index 0000000..27b5fab --- /dev/null +++ b/packages/commands/tests/qr.test.ts @@ -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(" { + 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); + } +}); diff --git a/packages/commands/tests/search-web-activate-hint.test.ts b/packages/commands/tests/search-web-activate-hint.test.ts new file mode 100644 index 0000000..045185c --- /dev/null +++ b/packages/commands/tests/search-web-activate-hint.test.ts @@ -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); + } + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 7526d0a..a198d5b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": { @@ -40,11 +40,13 @@ "check": "vp check" }, "dependencies": { + "tar-stream": "catalog:", "yaml": "^2.8.3", "yauzl": "catalog:" }, "devDependencies": { "@types/node": "catalog:", + "@types/tar-stream": "catalog:", "@types/yauzl": "catalog:", "@typescript/native-preview": "7.0.0-dev.20260328.1", "typescript": "^6.0.2", diff --git a/packages/core/src/advisor/index.ts b/packages/core/src/advisor/index.ts index 78e85ec..1d78f0b 100644 --- a/packages/core/src/advisor/index.ts +++ b/packages/core/src/advisor/index.ts @@ -7,6 +7,7 @@ export { recallCandidates } from "./recall.ts"; export { recallSemantic, isSemanticAvailable } from "./recall-semantic.ts"; export type { RecommendOptions } from "./recommend.ts"; export { buildDocLink, rankModels } from "./recommend.ts"; +export { maybeSyncWikiData } from "./sync.ts"; export type { ModelSource } from "./sources/types.ts"; export type { Budget, diff --git a/packages/core/src/advisor/sources/catalog.ts b/packages/core/src/advisor/sources/catalog.ts index 7a90fa5..723c757 100644 --- a/packages/core/src/advisor/sources/catalog.ts +++ b/packages/core/src/advisor/sources/catalog.ts @@ -1,6 +1,5 @@ -import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { getConfigDir } from "../../config/paths.ts"; import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts"; import type { ModelSource } from "./types.ts"; @@ -13,12 +12,9 @@ function getCatalogDir(): string { } function getCatalogPath(): string { - return join(getCatalogDir(), MODELS_FILE); -} - -function getMonorepoModelsDir(): string { - const coreDir = dirname(fileURLToPath(import.meta.url)); - return join(coreDir, "../../../../../skills/bailian-docs-llm-wiki/models"); + // Full-package layout keeps the `models/` subdir (raw/, wiki/, models/, …), + // so models.jsonl lives at /models/models.jsonl — not at the skill root. + return join(getCatalogDir(), "models", MODELS_FILE); } function fromJsonlRecord(raw: Record): ModelProfile | null { @@ -62,41 +58,24 @@ function readJsonlModels(filePath: string): ModelProfile[] { return models; } -function installFromMonorepo(): boolean { - const src = getMonorepoModelsDir(); - if (!existsSync(join(src, MODELS_FILE))) return false; - const dest = getCatalogDir(); - try { - mkdirSync(dest, { recursive: true }); - cpSync(src, dest, { recursive: true }); - return true; - } catch { - return false; - } -} - export interface CatalogSourceOptions { onPrepareStart?: () => void; } export class CatalogSource implements ModelSource { readonly name = "catalog"; - private options: CatalogSourceOptions; - constructor(options?: CatalogSourceOptions) { - this.options = options ?? {}; - } + // Options retained for API compatibility. Data is now always provisioned by + // the CLI postinstall hook and refreshed by advisor sync, so the previous + // `onPrepareStart` install callback is obsolete. + constructor(_options?: CatalogSourceOptions) {} available(): boolean { return existsSync(getCatalogPath()); } async load(): Promise { - if (!this.available()) { - this.options.onPrepareStart?.(); - const installed = installFromMonorepo(); - if (!installed) return []; - } + if (!this.available()) return []; return readJsonlModels(getCatalogPath()); } } diff --git a/packages/core/src/advisor/sync.ts b/packages/core/src/advisor/sync.ts new file mode 100644 index 0000000..6e36ce7 --- /dev/null +++ b/packages/core/src/advisor/sync.ts @@ -0,0 +1,157 @@ +/** + * sync.ts — Wiki data sync (layer 2: triggered by recommend) + * + * Called via `maybeSyncWikiData()` during `bl advisor recommend`: + * 1. 12h throttle: skip if last check was less than 12h ago + * 2. Download skills/index.json from public-read OSS, compare bailian-docs-llm-wiki entry version + * 3. Same version → only refresh lastChecked + * 4. Different version → delegate to the shared skill install pipeline + * (installSkill: download + extract + SKILL.md validate + atomic swap; + * linkSkillToAgents: fan-out symlinks to detected agents; + * upsertSkillLockEntry: write lock WITH links so bl skill remove can reclaim correctly) + * + * Protocol: unified skill publishing protocol (FC publish-skills, all skills are isomorphic), entry point is + * skills/index.json, one content-addressed object per skill (sha256-.tar.br, brotli q6; + * legacy fallback skill.tar.br). + * + * Complements postinstall.js (layer 1, unconditional overwrite on npm install). Install, extraction, + * validation, fan-out and lock writing all reuse the skills/ module (same as bl skill add), symmetric + * with publisher tar.pack(). + * + * Failure strategy: any step failure silently returns without updating lastChecked; next recommend retries immediately. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths.ts"; +import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts"; +import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts"; +import { fetchSkillsIndex } from "../skills/registry.ts"; +import type { SkillIndexEntry, SkillLockEntry } from "../skills/types.ts"; + +const WIKI_SKILL_NAME = "bailian-docs-llm-wiki"; +const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki"; +const STATE_FILE_NAME = "wiki-sync-state.json"; +const MODELS_FILE = "models.jsonl"; + +const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h +/** Tighter than the interactive default: the silent channel must not stall `bl advisor recommend` */ +const INDEX_TIMEOUT_MS = 3000; + +interface SyncState { + lastChecked: number; + /** Content fingerprint of the last synced revision; the change-detection token */ + contentHash: string; +} + +function getCatalogDir(): string { + return join(getConfigDir(), SKILL_DIR_NAME); +} + +/** + * Whether local Wiki data is ready. Uses `models.jsonl` as the existence signal, consistent with + * `CatalogSource.available()`: as long as the file advisor actually consumes exists, + * the data is considered available. + */ +function catalogDataExists(): boolean { + return existsSync(join(getCatalogDir(), "models", MODELS_FILE)); +} + +function getStatePath(): string { + return join(getConfigDir(), STATE_FILE_NAME); +} + +function readState(): SyncState | null { + try { + return JSON.parse(readFileSync(getStatePath(), "utf-8")) as SyncState; + } catch { + return null; + } +} + +function writeState(state: SyncState): void { + try { + writeFileSync(getStatePath(), JSON.stringify(state)); + } catch { + /* Non-critical: if state write fails, next run will re-check */ + } +} + +/** + * Record this sync in skill-lock.json so the wiki skill shares the same ledger as bl skill + * (list shows installed instead of untracked; update/remove can manage it correctly). + * Includes fan-out links so bl skill remove can reclaim agent symlinks. + * Bookkeeping in the silent channel must be best-effort: failure does not affect sync results. + */ +function recordWikiInLock(lockEntry: SkillLockEntry): void { + try { + upsertSkillLockEntry(WIKI_SKILL_NAME, lockEntry); + } catch { + /* Bookkeeping failure does not block sync; next sync or bl skill add will fill it in */ + } +} + +/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */ +function wikiLockUpToDate(contentHash: string): boolean { + try { + return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash; + } catch { + return false; + } +} + +/** Fetch skills/index.json via the shared registry client and extract the wiki skill entry; returns null on any failure */ +async function fetchIndexEntry(): Promise { + try { + const index = await fetchSkillsIndex(INDEX_TIMEOUT_MS); + return index.skills[WIKI_SKILL_NAME] ?? null; + } catch { + return null; + } +} + +/** + * Check and sync Wiki data. Runs silently; never throws. + * @returns Whether data was actually updated (for testing/debugging) + */ +export async function maybeSyncWikiData(): Promise { + const state = readState(); + const now = Date.now(); + + // 1. throttle gate: only skip when "within the 12h window" AND "local data actually exists". + // If data is missing (user deleted manually, postinstall failed but state remains, etc.), + // ignore throttle and sync immediately to ensure advisor has data. + if (state && now - state.lastChecked < THROTTLE_MS && catalogDataExists()) { + return false; + } + + // 2. Fetch skills/index.json and get the wiki entry + const entry = await fetchIndexEntry(); + if (!entry?.contentHash) return false; // On failure, do not write lastChecked; retry next time + + // 3. Same content and local data exists: only refresh lastChecked, no re-download needed. + // Covers two cases: (a) state.contentHash === entry.contentHash → direct hit; + // (b) state missing but data intact (user or accident only deleted state) → write the fingerprint + // back to state, avoiding unnecessary download+extract. + // If data is missing or the fingerprint differs, falls through to step 4 for full download. + const dataOk = catalogDataExists(); + if (dataOk && (!state || state.contentHash === entry.contentHash)) { + writeState({ lastChecked: now, contentHash: entry.contentHash }); + // Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill + if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, [])); + return false; + } + + // 4. Different content or missing data: delegate to the shared skill install pipeline + // (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links) + try { + const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry); + recordWikiInLock(record.lockEntry); + } catch { + // Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries + return false; + } + + // 5. Success: write state + writeState({ lastChecked: now, contentHash: entry.contentHash }); + return true; +} diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 9d52abb..914abce 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -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 { diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 55369ae..119c7bd 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -7,15 +7,26 @@ export function chatPath(): string { } // ---- Image Generation (DashScope) ---- +/** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */ export function imagePath(): string { return "/api/v1/services/aigc/image-generation/generation"; } -// Synchronous image generation (qwen-image-2.0 / qwen-image-max series) +/** Sync multimodal API (qwen-image / wan2.7-image / z-image generate; also wan2.6-image edit). */ export function imageSyncPath(): string { return "/api/v1/services/aigc/multimodal-generation/generation"; } +/** Legacy async text-to-image API (wan2.5/2.2/2.1-t2i, wanx-*-t2i). */ +export function imageText2ImagePath(): string { + return "/api/v1/services/aigc/text2image/image-synthesis"; +} + +/** Legacy async image-to-image / edit API (wan2.5-i2i, *imageedit*). */ +export function image2ImagePath(): string { + return "/api/v1/services/aigc/image2image/image-synthesis"; +} + // ---- Video Generation (DashScope) ---- export function videoGeneratePath(): string { return "/api/v1/services/aigc/video-generation/video-synthesis"; diff --git a/packages/core/src/client/image-routes.ts b/packages/core/src/client/image-routes.ts new file mode 100644 index 0000000..2c532e8 --- /dev/null +++ b/packages/core/src/client/image-routes.ts @@ -0,0 +1,231 @@ +import { image2ImagePath, imagePath, imageSyncPath, imageText2ImagePath } from "./endpoints.ts"; + +/** + * DashScope image APIs differ by model family: + * + * Generate (T2I): + * - sync multimodal + messages: qwen-image*, wan2.7-image*, z-image* + * - async image-generation + messages: wan2.6-t2i*, wan2.6-image* + * (wan2.6-image sync multimodal requires 1–4 images, so pure T2I must be async) + * - async text2image + prompt: wan2.5/2.2/2.1-t2i*, wanx*-t2i* + * + * Edit (I2I): + * - sync multimodal + messages(+images): qwen-image-2.0*, qwen-image-edit*, wan2.6-image*, wan2.7-image* + * (pure T2I models such as z-image / qwen-image-plus / qwen-image-max are NOT edit models) + * - async image2image + prompt/images: wan2.5-i2i* + * - async image2image + function/base_image_url: *imageedit* (e.g. wanx2.1-imageedit) + * - async image-generation + messages(+images): other async fallbacks + */ + +export type ImageApiKind = + | "sync-multimodal" + | "async-image-generation" + | "async-text2image" + | "async-image2image"; + +/** Model-family size presets — not inferred from sync/async. */ +export type ImageSizeProfile = + | "qwen-image-2.0" + | "qwen-image-fixed" + | "wan27" + | "z-image" + | "wan26" + | "wan-legacy" + | "wanx-v1" + | "wan25-i2i"; + +export type ImageInputStyle = "messages" | "prompt" | "prompt-images" | "function-base-image"; + +export interface ImageApiRoute { + kind: ImageApiKind; + path: string; + /** True when the call is synchronous (no X-DashScope-Async / task poll). */ + useSync: boolean; + /** How to shape `input` in the request body. */ + inputStyle: ImageInputStyle; + /** Ratio → pixel map family for `--size`. */ + sizeProfile: ImageSizeProfile; + /** + * CLI default when `--prompt-extend` is omitted. + * `undefined` means omit the parameter (leave to DashScope default). + */ + promptExtendDefault?: boolean; +} + +/** Models that accept text-only sync multimodal for generate. */ +const SYNC_GENERATE_PREFIXES = ["qwen-image", "wan2.7-image", "z-image"] as const; + +/** + * Models that support sync multimodal edit (messages must include images). + * Pure T2I models (z-image / qwen-image-plus / qwen-image-max) are excluded. + */ +const SYNC_EDIT_PREFIXES = [ + "qwen-image-2.0", + "qwen-image-edit", + "wan2.7-image", + "wan2.6-image", +] as const; + +function startsWithAny(model: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => model.startsWith(prefix)); +} + +/** True when the model family can use sync multimodal for generate. */ +export function isSyncMultimodalImageModel(model: string): boolean { + return startsWithAny(model, SYNC_GENERATE_PREFIXES) || model.startsWith("wan2.6-image"); +} + +function isSyncGenerateModel(model: string): boolean { + return startsWithAny(model, SYNC_GENERATE_PREFIXES); +} + +function isSyncEditModel(model: string): boolean { + return startsWithAny(model, SYNC_EDIT_PREFIXES); +} + +/** wanx-v1 and dated aliases (e.g. wanx-v1-0521) use the legacy text2image API. */ +function isWanxV1Model(model: string): boolean { + return /^wanx-v1(?:-|$)/i.test(model); +} + +/** wan2.5 / wan2.2 / wan2.1 / wanx text-to-image models use the legacy prompt API. */ +export function isLegacyText2ImageModel(model: string): boolean { + if (model.startsWith("wan2.6-t2i") || model.startsWith("wan2.6-image")) return false; + if (isSyncGenerateModel(model)) return false; + if (/^wan2\.[0-5][^-]*-t2i/i.test(model)) return true; + if (isWanxV1Model(model)) return true; + if (/^wanx/i.test(model) && /t2i|text2image/i.test(model)) return true; + return false; +} + +/** wan2.5-i2i uses the legacy image2image prompt+images API. */ +export function isLegacyImage2ImageModel(model: string): boolean { + return /wan2\.5-i2i/i.test(model); +} + +/** wanx*-imageedit uses function + base_image_url (not prompt+images). */ +export function isWanxFunctionImageEditModel(model: string): boolean { + return /imageedit/i.test(model); +} + +export function resolveImageSizeProfile(model: string): ImageSizeProfile { + if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-edit")) { + return "qwen-image-2.0"; + } + // Remaining qwen-image* (plus / max / bare qwen-image) share the fixed table. + if (model.startsWith("qwen-image")) { + return "qwen-image-fixed"; + } + if (model.startsWith("wan2.7-image")) return "wan27"; + if (model.startsWith("z-image")) return "z-image"; + if ( + model.startsWith("wan2.6-t2i") || + model.startsWith("wan2.6-image") || + model.startsWith("wan2.5-t2i") + ) { + return "wan26"; + } + if (isWanxV1Model(model)) return "wanx-v1"; + if (/wan2\.5-i2i/i.test(model)) return "wan25-i2i"; + if (isLegacyText2ImageModel(model)) return "wan-legacy"; + return "wan26"; +} + +/** Official / CLI defaults for prompt_extend when the flag is omitted. */ +export function resolvePromptExtendDefault(model: string): boolean | undefined { + if (model.startsWith("qwen-image-2.0") || model.startsWith("qwen-image-max")) return true; + // Z-Image docs default prompt_extend to false. + if (model.startsWith("z-image")) return false; + return undefined; +} + +function buildRoute( + partial: Omit, + model: string, +): ImageApiRoute { + return { + ...partial, + sizeProfile: resolveImageSizeProfile(model), + promptExtendDefault: resolvePromptExtendDefault(model), + }; +} + +export function resolveImageGenerateApi(model: string): ImageApiRoute { + if (isSyncGenerateModel(model)) { + return buildRoute( + { + kind: "sync-multimodal", + path: imageSyncPath(), + useSync: true, + inputStyle: "messages", + }, + model, + ); + } + if (isLegacyText2ImageModel(model)) { + return buildRoute( + { + kind: "async-text2image", + path: imageText2ImagePath(), + useSync: false, + inputStyle: "prompt", + }, + model, + ); + } + // Includes wan2.6-t2i* and wan2.6-image* (text-only generate). + return buildRoute( + { + kind: "async-image-generation", + path: imagePath(), + useSync: false, + inputStyle: "messages", + }, + model, + ); +} + +export function resolveImageEditApi(model: string): ImageApiRoute { + if (isSyncEditModel(model)) { + return buildRoute( + { + kind: "sync-multimodal", + path: imageSyncPath(), + useSync: true, + inputStyle: "messages", + }, + model, + ); + } + if (isWanxFunctionImageEditModel(model)) { + return buildRoute( + { + kind: "async-image2image", + path: image2ImagePath(), + useSync: false, + inputStyle: "function-base-image", + }, + model, + ); + } + if (isLegacyImage2ImageModel(model)) { + return buildRoute( + { + kind: "async-image2image", + path: image2ImagePath(), + useSync: false, + inputStyle: "prompt-images", + }, + model, + ); + } + return buildRoute( + { + kind: "async-image-generation", + path: imagePath(), + useSync: false, + inputStyle: "messages", + }, + model, + ); +} diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 16a4f8b..a10e28d 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -3,6 +3,8 @@ export { chatPath, imagePath, imageSyncPath, + imageText2ImagePath, + image2ImagePath, knowledgeChatEndpoint, knowledgeRetrievePath, knowledgeSearchEndpoint, @@ -18,6 +20,20 @@ export { userProfilePath, videoGeneratePath, } from "./endpoints.ts"; +export { + isLegacyImage2ImageModel, + isLegacyText2ImageModel, + isSyncMultimodalImageModel, + isWanxFunctionImageEditModel, + resolveImageEditApi, + resolveImageGenerateApi, + resolveImageSizeProfile, + resolvePromptExtendDefault, + type ImageApiKind, + type ImageApiRoute, + type ImageInputStyle, + type ImageSizeProfile, +} from "./image-routes.ts"; export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts"; export type { HttpDeps, RequestOpts } from "./http.ts"; export { request, requestJson } from "./http.ts"; diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts index f1d167c..9d8fc0d 100644 --- a/packages/core/src/config/profile-presets.ts +++ b/packages/core/src/config/profile-presets.ts @@ -10,7 +10,7 @@ interface ModelProfilePreset { const MODEL_PROFILE_PRESETS: Readonly> = { "token-plan": { baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", - defaultTextModel: "qwen3.8-max-preview", + defaultTextModel: "qwen3.8-max", defaultVideoModel: "happyhorse-1.1-t2v", defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", diff --git a/packages/core/src/dataset/api.ts b/packages/core/src/dataset/api.ts index 8bb9d51..c7cb1d6 100644 --- a/packages/core/src/dataset/api.ts +++ b/packages/core/src/dataset/api.ts @@ -47,7 +47,7 @@ export interface DatasetUploadParams { export async function uploadDataset( client: Client, params: DatasetUploadParams, -): Promise { +): Promise { const { filePath, purpose = "fine-tune", signal } = params; const stat = statSync(filePath); const fileName = basename(filePath); @@ -75,6 +75,7 @@ export async function uploadDataset( size: body.bytes ?? stat.size, purpose: body.purpose ?? purpose, gmt_create: body.created_at ? new Date(body.created_at * 1000).toISOString() : undefined, + request_id: body.request_id, }; } // No id in response → upload reported HTTP 200 but produced no usable record diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e49ee6c..6db1199 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,4 @@ export * from "./types/index.ts"; export * from "./utils/index.ts"; export * from "./telemetry/index.ts"; export * from "./advisor/index.ts"; +export * from "./skills/index.ts"; diff --git a/packages/core/src/skills/agents.ts b/packages/core/src/skills/agents.ts new file mode 100644 index 0000000..135fa0e --- /dev/null +++ b/packages/core/src/skills/agents.ts @@ -0,0 +1,178 @@ +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { getSkillsDir } from "./lock.ts"; + +/** + * Agent fan-out: after a skill lands in the canonical dir (~/.bailian/skills/), + * symlink it into each detected AI agent's global skills directory so that a single + * install becomes visible across all agents. + * + * Detection semantics: if the agent's config dir exists → agent is installed → create link; + * otherwise skip (never create ~/.xxx dirs that pollute home). When a new agent is installed + * later, any subsequent `bl skill add/update` will fill in missing links (self-healing). + */ +export interface AgentTarget { + id: string; + displayName: string; + /** Global directory where this agent reads skills from */ + skillsDir: string; + /** 任一存在即判定"本机装了该 agent" */ + detectDirs: string[]; +} + +/** Computed on each call (depends on homedir / XDG_CONFIG_HOME; easy to override in tests) */ +export function getAgentTargets(): AgentTarget[] { + const home = homedir(); + const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config"); + const simple = (id: string, displayName: string, dir: string): AgentTarget => ({ + id, + displayName, + skillsDir: join(home, dir, "skills"), + detectDirs: [join(home, dir)], + }); + return [ + // universal pseudo-agent: ~/.agents/skills is a shared dir read by multiple agents (Cline, etc.) + { + id: "universal", + displayName: "Universal (~/.agents/skills)", + skillsDir: join(home, ".agents", "skills"), + detectDirs: [join(home, ".agents"), join(home, ".cline")], + }, + simple("claude-code", "Claude Code", ".claude"), + simple("openclaw", "OpenClaw", ".openclaw"), + simple("hermes", "Hermes Agent", ".hermes"), + { + id: "opencode", + displayName: "OpenCode", + skillsDir: join(xdgConfig, "opencode", "skills"), + detectDirs: [join(xdgConfig, "opencode")], + }, + simple("cursor", "Cursor", ".cursor"), + simple("codex", "Codex", ".codex"), + simple("qwen-code", "Qwen Code", ".qwen"), + simple("qoder", "Qoder", ".qoder"), + simple("qoder-cn", "Qoder CN", ".qoder-cn"), + simple("kilo", "Kilo Code", ".kilocode"), + ]; +} + +export function detectInstalledAgents(): AgentTarget[] { + return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir))); +} + +/** Whether linkPath is managed by this tool: a symlink whose resolved target falls within the canonical skills dir */ +function isManagedLink(linkPath: string): boolean { + try { + if (!lstatSync(linkPath).isSymbolicLink()) return false; + const target = readlinkSync(linkPath); + const abs = isAbsolute(target) ? target : resolve(dirname(linkPath), target); + return abs === getSkillsDir() || abs.startsWith(getSkillsDir() + sep); + } catch { + return false; + } +} + +export interface LinkResult { + agent: string; + path: string; + mode: "symlink" | "copy" | "skipped"; + reason?: string; +} + +/** + * Fan out a skill from canonical to each agent's skills dir. + * Stale links created by this tool are rebuilt; existing files/dirs NOT managed by this tool + * are always skipped (never delete user content). Falls back to copy when symlink fails + * (e.g. Windows without Developer Mode). + */ +export function linkSkillToAgents( + name: string, + agents: AgentTarget[] = detectInstalledAgents(), +): LinkResult[] { + const target = join(getSkillsDir(), name); + const results: LinkResult[] = []; + for (const agent of agents) { + const linkPath = join(agent.skillsDir, name); + try { + let existing = false; + try { + lstatSync(linkPath); // existsSync returns false for dangling symlinks; must use lstat + existing = true; + } catch { + /* does not exist */ + } + if (existing) { + if (!isManagedLink(linkPath)) { + results.push({ + agent: agent.id, + path: linkPath, + mode: "skipped", + reason: "existing file/dir not managed by bl skill", + }); + continue; + } + rmSync(linkPath); + } + mkdirSync(agent.skillsDir, { recursive: true }); + try { + symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir"); + results.push({ agent: agent.id, path: linkPath, mode: "symlink" }); + } catch { + // No symlink permission (typical: Windows non-Developer Mode) → fall back to copy + cpSync(target, linkPath, { recursive: true }); + results.push({ agent: agent.id, path: linkPath, mode: "copy" }); + } + } catch (err) { + results.push({ + agent: agent.id, + path: linkPath, + mode: "skipped", + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return results; +} + +/** + * Reclaim fan-out artifacts for a skill across all agent dirs. + * Symlinks pointing to canonical are removed (including historical links not in lock, + * via defensive scan of the full registry); real directories are only removed if recorded + * in lock (copy-fallback artifacts). A single failure does not block the rest. + */ +export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] { + const removed: string[] = []; + const candidates = new Set(recordedLinks); + for (const agent of getAgentTargets()) candidates.add(join(agent.skillsDir, name)); + for (const linkPath of candidates) { + try { + let stat; + try { + stat = lstatSync(linkPath); + } catch { + continue; + } + if (stat.isSymbolicLink()) { + if (isManagedLink(linkPath)) { + rmSync(linkPath); + removed.push(linkPath); + } + } else if (recordedLinks.includes(linkPath)) { + rmSync(linkPath, { recursive: true, force: true }); + removed.push(linkPath); + } + } catch { + /* single failure does not block remaining cleanup */ + } + } + return removed; +} diff --git a/packages/core/src/skills/extract.ts b/packages/core/src/skills/extract.ts new file mode 100644 index 0000000..48a8a99 --- /dev/null +++ b/packages/core/src/skills/extract.ts @@ -0,0 +1,99 @@ +/** + * tar.br archive extraction and atomic swap — shared by advisor wiki sync and `bl skill` install. + * Symmetric with the publisher (FC skills-publish.mjs: tar.pack + brotli); uses only Node built-in + * zlib + tar-stream, no extra decompression dependencies. + */ +import { + createWriteStream, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createBrotliDecompress } from "node:zlib"; +import tar from "tar-stream"; + +/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */ +export function isSafeEntryName(name: string): boolean { + if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false; + return !name.split("/").includes(".."); +} + +/** Brotli decompress + tar-stream extract into destDir (per-entry path safety check). */ +export async function extractTarBr(tarBrBuffer: Buffer, destDir: string): Promise { + const extract = tar.extract(); + + extract.on("entry", (header, stream, next) => { + if (!isSafeEntryName(header.name)) { + // Use destroy so the pipeline rejects with this error; silence the entry stream + // to avoid its companion error becoming an unhandled exception + stream.on("error", () => {}); + stream.resume(); + extract.destroy(new Error(`unsafe tar entry: ${header.name}`)); + return; + } + const filePath = join(destDir, header.name); + if (header.type === "directory") { + mkdirSync(filePath, { recursive: true }); + stream.resume(); + stream.on("end", next); + return; + } + mkdirSync(dirname(filePath), { recursive: true }); + const ws = createWriteStream(filePath); + stream.pipe(ws); + ws.on("finish", next); + ws.on("error", next); + }); + + await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract); +} + +/** + * Recompute the publisher's deterministic content hash over an extracted directory: + * regular files sorted by "/"-separated relative path (code-unit order, same as the + * publisher's byte-order sort for ASCII paths), sha256 accumulating relPath + bytes. + * Symmetric with computeContentHash in FC skills-publish.mjs. + */ +export function computeDirContentHash(dir: string): string { + const relPaths: string[] = []; + const walk = (sub: string): void => { + for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) { + const rel = sub ? `${sub}/${dirent.name}` : dirent.name; + if (dirent.isDirectory()) walk(rel); + else if (dirent.isFile()) relPaths.push(rel); + } + }; + walk(""); + relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + const hash = createHash("sha256"); + for (const rel of relPaths) { + hash.update(rel); + hash.update(readFileSync(join(dir, rel))); + } + return `sha256:${hash.digest("hex")}`; +} + +/** + * Atomic swap: replace destDir with the extracted content from tmpDir. + * tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic. + */ +export function atomicSwap(tmpDir: string, destDir: string): void { + mkdirSync(dirname(destDir), { recursive: true }); + const backup = `${destDir}.old-${Date.now()}`; + if (existsSync(destDir)) renameSync(destDir, backup); + try { + renameSync(tmpDir, destDir); + } catch (err) { + // Swap failed → roll back the old directory to avoid leaving a hole + if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir); + throw err; + } + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); +} diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts new file mode 100644 index 0000000..58529b2 --- /dev/null +++ b/packages/core/src/skills/index.ts @@ -0,0 +1,45 @@ +// bl skill management: OSS unified publishing protocol client + local install/fan-out/status reconciliation. +export type { + SkillIndexEntry, + SkillsIndex, + SkillLockEntry, + SkillLockFile, + SkillStatus, + SkillStatusRow, +} from "./types.ts"; +export { + getSkillRegistryBaseUrl, + fetchSkillsIndex, + downloadSkillAsset, + resolveAssetFileName, +} from "./registry.ts"; +export { + getSkillsDir, + getSkillLockPath, + emptySkillLock, + readSkillLock, + writeSkillLock, + upsertSkillLockEntry, +} from "./lock.ts"; +export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts"; +export { parseSkillNames } from "./names.ts"; +export { validateSkillDir, type SkillMeta } from "./validate.ts"; +export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from "./extract.ts"; +export { + getAgentTargets, + detectInstalledAgents, + linkSkillToAgents, + unlinkSkillFromAgents, + type AgentTarget, + type LinkResult, +} from "./agents.ts"; +export { + installSkill, + installSkillFromBuffer, + installSkillWithFanout, + buildSkillLockEntry, + removeSkillDir, + type InstalledSkill, + type SkillInstallRecord, +} from "./installer.ts"; +export { listSkillDirsOnDisk, computeSkillStatuses } from "./status.ts"; diff --git a/packages/core/src/skills/installer.ts b/packages/core/src/skills/installer.ts new file mode 100644 index 0000000..0bcba40 --- /dev/null +++ b/packages/core/src/skills/installer.ts @@ -0,0 +1,133 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import { detectInstalledAgents, linkSkillToAgents, type AgentTarget } from "./agents.ts"; +import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts"; +import { getSkillsDir } from "./lock.ts"; +import { downloadSkillAsset } from "./registry.ts"; +import { isSafeSkillName } from "./sanitize.ts"; +import { validateSkillDir, type SkillMeta } from "./validate.ts"; +import type { SkillIndexEntry, SkillLockEntry } from "./types.ts"; + +/** + * Skill installer: download → extract to tmpdir (with tar-slip check) → validate SKILL.md → + * atomic swap into canonical. Canonical is only touched after all validations pass; on any failure + * the current installation is preserved and temp artifacts are cleaned up in finally. + */ +export interface InstalledSkill { + name: string; + path: string; + meta: SkillMeta; +} + +function assertSafeName(name: string): void { + if (!isSafeSkillName(name)) { + throw new BailianError( + `Invalid skill name: ${name}`, + ExitCode.GENERAL, + "Skill name contains path separators, traversal sequences, or other illegal characters; refusing to write to disk", + ); + } +} + +/** Install from an in-memory tar.br archive (the download-and-onwards half of installSkill; test-friendly) */ +export async function installSkillFromBuffer( + name: string, + tarBrBuffer: Buffer, + expectedContentHash?: string, +): Promise { + assertSafeName(name); + const skillsDir = getSkillsDir(); + const dest = join(skillsDir, name); + // Same-volume temp dir: extract here then rename; cross-device rename would EXDEV + const tmpDir = join(skillsDir, `.tmp-${name}-${process.pid}-${Date.now()}`); + try { + mkdirSync(tmpDir, { recursive: true }); + await extractTarBr(tarBrBuffer, tmpDir); + // Integrity check before touching canonical: recompute the publisher fingerprint over + // the extracted files; on mismatch the current installation is left untouched + if (expectedContentHash?.startsWith("sha256:")) { + const actualContentHash = computeDirContentHash(tmpDir); + if (actualContentHash !== expectedContentHash) { + throw new BailianError( + `Skill ${name} failed integrity check: index says ${expectedContentHash}, archive is ${actualContentHash}`, + ExitCode.GENERAL, + "Downloaded archive does not match the index fingerprint (registry may be mid-publish); retry later", + ); + } + } + const meta = validateSkillDir(tmpDir, name); + atomicSwap(tmpDir, dest); + return { name, path: dest, meta }; + } finally { + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); + } +} + +/** Install a single skill by index entry (download + validate + write to disk) */ +export async function installSkill(name: string, entry: SkillIndexEntry): Promise { + if (entry.compression && entry.compression !== "tar.br") { + throw new BailianError( + `Skill ${name} uses unsupported compression format: ${entry.compression}`, + ExitCode.GENERAL, + "Upgrade bailian-cli to the latest version and retry", + ); + } + const buffer = await downloadSkillAsset(name, entry); + return installSkillFromBuffer(name, buffer, entry.contentHash); +} + +/** Remove the skill directory under canonical; returns whether it was actually deleted (dir absent → false) */ +export function removeSkillDir(name: string): boolean { + assertSafeName(name); + const dest = join(getSkillsDir(), name); + if (!existsSync(dest)) return false; + rmSync(dest, { recursive: true, force: true }); + return true; +} + +/** + * Build a skill-lock entry from an index entry + effective fan-out link paths. + * Single source of truth for the "installation fact" shape shared by bl skill add/update, + * advisor wiki sync, and any future install channel. + */ +export function buildSkillLockEntry(entry: SkillIndexEntry, links: string[]): SkillLockEntry { + return { + ...(entry.contentHash ? { contentHash: entry.contentHash } : {}), + ...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}), + installedAt: new Date().toISOString(), + sourceType: "oss", + ...(entry.description ? { description: entry.description } : {}), + links, + }; +} + +export interface SkillInstallRecord { + /** Ready-to-persist lock entry (links = effective fan-out paths) */ + lockEntry: SkillLockEntry; + /** Ids of agents that actually received a link/copy (skipped ones excluded) */ + linkedAgents: string[]; +} + +/** + * Full install workflow for one skill: install into canonical, fan out to agents, and build + * the lock entry recording effective links. Callers decide how to persist the lock entry + * (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels). + */ +export async function installSkillWithFanout( + name: string, + entry: SkillIndexEntry, + agents: AgentTarget[] = detectInstalledAgents(), +): Promise { + await installSkill(name, entry); + const links = linkSkillToAgents(name, agents); + const effective = links.filter((link) => link.mode !== "skipped"); + return { + lockEntry: buildSkillLockEntry( + entry, + effective.map((link) => link.path), + ), + linkedAgents: effective.map((link) => link.agent), + }; +} diff --git a/packages/core/src/skills/lock.ts b/packages/core/src/skills/lock.ts new file mode 100644 index 0000000..f87d97c --- /dev/null +++ b/packages/core/src/skills/lock.ts @@ -0,0 +1,59 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths.ts"; +import type { SkillLockEntry, SkillLockFile } from "./types.ts"; + +/** + * Local skill state: canonical directory + skill-lock.json. + * + * The lock only records "installation facts" (version, timestamp, fan-out links) and never + * caches the remote index — list/update diffs are always "live remote index vs lock". + * Paths follow the config.json directory logic (BAILIAN_CONFIG_DIR can redirect everything). + */ +export function getSkillsDir(): string { + return join(getConfigDir(), "skills"); +} + +export function getSkillLockPath(): string { + return join(getSkillsDir(), "skill-lock.json"); +} + +export function emptySkillLock(): SkillLockFile { + return { version: 1, skills: {} }; +} + +/** + * Read installation records. Returns an empty lock when the file is absent (first install), + * corrupted, or has an unrecognized version — an empty lock is a valid initial state, not an + * error; subsequent install actions will rebuild correct records. + */ +export function readSkillLock(): SkillLockFile { + const path = getSkillLockPath(); + if (!existsSync(path)) return emptySkillLock(); + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as SkillLockFile; + if (parsed?.version !== 1 || typeof parsed.skills !== "object" || parsed.skills === null) { + return emptySkillLock(); + } + return parsed; + } catch { + return emptySkillLock(); + } +} + +export function writeSkillLock(lock: SkillLockFile): void { + mkdirSync(getSkillsDir(), { recursive: true }); + writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n"); +} + +/** + * Merge-update a single skill's installation record (read-modify-write). + * Shallow-merges with the existing entry: fields not provided in patch (typically links — + * agent fan-out records) are preserved, preventing "install-only, no fan-out" sync channels + * like postinstall/advisor from overwriting link records established by bl skill add. + */ +export function upsertSkillLockEntry(name: string, patch: SkillLockEntry): void { + const lock = readSkillLock(); + lock.skills[name] = { ...lock.skills[name], ...patch }; + writeSkillLock(lock); +} diff --git a/packages/core/src/skills/names.ts b/packages/core/src/skills/names.ts new file mode 100644 index 0000000..397664c --- /dev/null +++ b/packages/core/src/skills/names.ts @@ -0,0 +1,30 @@ +import { UsageError } from "../errors/base.ts"; + +/** + * Parse --name: `all` or a comma-separated list of skill names (deduplicated, trimmed). + * `all` cannot be mixed with specific names. + */ +export function parseSkillNames(raw: string | undefined, defaultAll: boolean): string[] | "all" { + const value = (raw ?? (defaultAll ? "all" : "")).trim(); + if (!value) { + throw new UsageError("--name cannot be empty", "Use --name all or --name skill-a,skill-b"); + } + const parts = [ + ...new Set( + value + .split(",") + .map((part) => part.trim()) + .filter(Boolean), + ), + ]; + if (parts.includes("all")) { + if (parts.length > 1) { + throw new UsageError( + "--name all cannot be mixed with specific skill names", + "Use either all or a comma-separated list of names", + ); + } + return "all"; + } + return parts; +} diff --git a/packages/core/src/skills/registry.ts b/packages/core/src/skills/registry.ts new file mode 100644 index 0000000..9bbf260 --- /dev/null +++ b/packages/core/src/skills/registry.ts @@ -0,0 +1,112 @@ +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import type { SkillIndexEntry, SkillsIndex } from "./types.ts"; + +/** + * Skill registry client: public-read OSS, pure HTTPS GET, zero credentials (usable with auth: "none"). + * Defaults to the skills/ prefix of the bailian-wiki bucket; override with BAILIAN_SKILL_REGISTRY_URL + * for canary/private mirror scenarios. + */ +const DEFAULT_REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills"; + +const INDEX_TIMEOUT_MS = 10_000; +const ASSET_TIMEOUT_MS = 120_000; + +export function getSkillRegistryBaseUrl(): string { + const override = process.env.BAILIAN_SKILL_REGISTRY_URL?.trim(); + return (override || DEFAULT_REGISTRY_BASE_URL).replace(/\/+$/, ""); +} + +/** + * Fetch the remote skill index. No local caching — the diff comparison is always + * "live remote index vs local skill-lock.json". + * Silent background channels (advisor sync) may pass a tighter timeout than the interactive default. + */ +export async function fetchSkillsIndex(timeoutMs: number = INDEX_TIMEOUT_MS): Promise { + const url = `${getSkillRegistryBaseUrl()}/index.json`; + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + } catch (err) { + throw new BailianError( + `Cannot access skill registry: ${url}`, + ExitCode.NETWORK, + "Check network connectivity; if using a private mirror, verify BAILIAN_SKILL_REGISTRY_URL configuration", + { cause: err }, + ); + } + if (!res.ok) { + throw new BailianError( + `Skill registry returned HTTP ${res.status}: ${url}`, + ExitCode.NETWORK, + res.status === 404 + ? "Skill index not yet published or registry URL is incorrect; confirm the publisher has generated index.json" + : "Remote error, retry later", + ); + } + let parsed: unknown; + try { + parsed = await res.json(); + } catch (err) { + throw new BailianError( + "Skill index index.json is not valid JSON", + ExitCode.GENERAL, + "Remote may be in the middle of publishing, retry later", + { cause: err }, + ); + } + const index = parsed as SkillsIndex; + if ( + typeof index !== "object" || + index === null || + typeof index.skills !== "object" || + index.skills === null + ) { + throw new BailianError( + "Skill index index.json has invalid structure", + ExitCode.GENERAL, + "Retry later or contact the publisher", + ); + } + return index; +} + +/** + * Strict shape check for entry.object (defense against a hostile/corrupted index — + * anything not matching falls back to the legacy fixed key, never into the URL path). + */ +const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/; + +/** Resolve which file to download for a skill: content-addressed object, else legacy fixed key */ +export function resolveAssetFileName(entry?: SkillIndexEntry): string { + const object = entry?.object; + return object && OBJECT_FILE_RE.test(object) ? object : "skill.tar.br"; +} + +/** Download the tar.br archive for a single skill (one skill = one GET) */ +export async function downloadSkillAsset(name: string, entry?: SkillIndexEntry): Promise { + const url = `${getSkillRegistryBaseUrl()}/${name}/${resolveAssetFileName(entry)}`; + let res: Response; + try { + res = await fetch(url, { signal: AbortSignal.timeout(ASSET_TIMEOUT_MS) }); + } catch (err) { + throw new BailianError( + `Failed to download skill ${name}: ${url}`, + ExitCode.NETWORK, + "Network error, retryable", + { + cause: err, + }, + ); + } + if (!res.ok) { + throw new BailianError( + `Failed to download skill ${name}: HTTP ${res.status}`, + ExitCode.NETWORK, + res.status === 404 + ? "index.json and skill object are temporarily inconsistent (publishing in progress), retry later" + : "Remote error, retry later", + ); + } + return Buffer.from(await res.arrayBuffer()); +} diff --git a/packages/core/src/skills/sanitize.ts b/packages/core/src/skills/sanitize.ts new file mode 100644 index 0000000..b36e5fb --- /dev/null +++ b/packages/core/src/skills/sanitize.ts @@ -0,0 +1,21 @@ +/** + * Sanitize a skill name into a safe directory name (semantics aligned with vercel-labs/skills sanitizeName): + * skill names come from the remote index (untrusted input) and are interpolated into file paths, so they + * must be disinfected first — path separators/drive letters/whitespace/Windows-illegal chars are collapsed + * to hyphens, `..` is destroyed, leading/trailing `.-` are stripped. + * + * `bl skill` uses this as an "equivalence check": if the sanitized name differs from the original, + * installation is rejected outright (the publisher already has an isomorphic allowlist; this is client-side defense-in-depth). + */ +export function sanitizeSkillName(name: string): string { + const sanitized = name + .replace(/[\\/:*?"<>|\s]+/g, "-") + .replace(/\.\.+/g, "-") + .replace(/^[-.]+|[-.]+$/g, ""); + return sanitized || "unnamed-skill"; +} + +/** Whether the skill name is already a safe directory name (unchanged after sanitization) */ +export function isSafeSkillName(name: string): boolean { + return name.length > 0 && sanitizeSkillName(name) === name; +} diff --git a/packages/core/src/skills/status.ts b/packages/core/src/skills/status.ts new file mode 100644 index 0000000..073130e --- /dev/null +++ b/packages/core/src/skills/status.ts @@ -0,0 +1,86 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { getSkillsDir } from "./lock.ts"; +import type { SkillLockFile, SkillStatusRow, SkillsIndex } from "./types.ts"; + +/** + * Three-way reconciliation for list: remote index (live) × skill-lock.json (installation facts) × disk (ground truth). + */ + +/** Scan skill directories under canonical (skipping hidden entries, tmp/backup remnants, and plain files) */ +export function listSkillDirsOnDisk(): string[] { + const dir = getSkillsDir(); + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((entry) => { + if (entry.startsWith(".")) return false; + if (entry.includes(".tmp-") || entry.includes(".old-")) return false; + try { + return statSync(join(dir, entry)).isDirectory(); + } catch { + return false; + } + }); +} + +export function computeSkillStatuses( + index: SkillsIndex, + lock: SkillLockFile, + diskNames: string[], +): SkillStatusRow[] { + const disk = new Set(diskNames); + const seen = new Set(); + const rows: SkillStatusRow[] = []; + + // Skills present in remote: derive status from lock/disk + for (const [name, entry] of Object.entries(index.skills)) { + seen.add(name); + const locked = lock.skills[name]; + if (locked) { + const status = !disk.has(name) + ? "missing" // was installed but dir was deleted; reinstall can fix + : locked.contentHash !== entry.contentHash + ? "outdated" + : "installed"; + rows.push({ + name, + status, + publishedAt: entry.publishedAt, + description: entry.description, + }); + } else if (disk.has(name)) { + // Dir exists but no install record (manually placed, or synced by postinstall/advisor or other channels) + rows.push({ + name, + status: "untracked", + publishedAt: entry.publishedAt, + description: entry.description, + }); + } else { + rows.push({ + name, + status: "not-installed", + publishedAt: entry.publishedAt, + description: entry.description, + }); + } + } + + // In lock but delisted from remote: still usable locally (installed) or dir also gone (missing) + for (const [name, locked] of Object.entries(lock.skills)) { + if (seen.has(name)) continue; + seen.add(name); + rows.push({ + name, + status: disk.has(name) ? "installed" : "missing", + publishedAt: locked.publishedAt, + description: locked.description, + }); + } + + // On disk but in neither lock nor remote → untracked + for (const name of diskNames) { + if (!seen.has(name)) rows.push({ name, status: "untracked" }); + } + + return rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts new file mode 100644 index 0000000..b6fa9aa --- /dev/null +++ b/packages/core/src/skills/types.ts @@ -0,0 +1,75 @@ +/** + * Data structures for the unified skill publishing protocol (symmetric with FC publisher skills-publish.mjs). + * + * Remote layout (public-read OSS, the sole data source for `bl skill`): + * /index.json — skill catalog (SkillsIndex) + * //sha256-.tar.br — content-addressed skill object (tar + brotli); + * entry.object names the exact file, so index.json is the single atomic commit point. + * Legacy fallback: //skill.tar.br (entries without object) + * + * Local layout: + * ~/.bailian/skills// — canonical install directory + * ~/.bailian/skills/skill-lock.json — installation fact records (SkillLockFile) + */ + +/** A single skill entry in index.json */ +export interface SkillIndexEntry { + /** Reserved for the skill's own semantic version (x.y.z); not yet populated by the publisher */ + version?: string; + /** Beijing-time publish timestamp; refreshed whenever content changes — the human-facing release marker */ + publishedAt?: string; + /** Extracted by the publisher from README.md first paragraph or SKILL.md frontmatter */ + description?: string; + /** Deterministic content fingerprint; the CLI uses this as the change-detection token (install/outdated) */ + contentHash?: string; + /** Compression format identifier, currently always "tar.br" */ + compression?: string; + /** + * Content-addressed object file name under //, e.g. "sha256-.tar.br". + * Absent on legacy entries — client falls back to the fixed key "skill.tar.br". + */ + object?: string; +} + +export interface SkillsIndex { + updatedAt?: string; + /** key = skill name (i.e. OSS directory name, download path, local install dir name) */ + skills: Record; +} + +/** Installation facts for a single skill in skill-lock.json */ +export interface SkillLockEntry { + /** Content fingerprint at install time; compared against the remote index to detect updates */ + contentHash?: string; + /** Publish timestamp of the installed revision (for display) */ + publishedAt?: string; + installedAt: string; + /** Reserved: future support for github/gitlab and other sources */ + sourceType: "oss"; + description?: string; + /** Fan-out link/copy paths to each agent; used for precise reclamation on remove */ + links?: string[]; +} + +export interface SkillLockFile { + version: 1; + skills: Record; +} + +/** + * Skill statuses for list: + * installed — lock record exists, dir on disk, content fingerprint matches remote + * outdated — lock record exists, dir on disk, remote content fingerprint differs + * not-installed — present in remote, absent locally + * missing — lock record exists but dir was deleted (reinstall can fix) + * untracked — dir on disk but no install record (manually placed or synced by other channels) + */ +export type SkillStatus = "installed" | "outdated" | "not-installed" | "missing" | "untracked"; + +export interface SkillStatusRow { + name: string; + status: SkillStatus; + /** Publish timestamp of the remote revision (or local, for delisted skills) */ + publishedAt?: string; + description?: string; +} diff --git a/packages/core/src/skills/validate.ts b/packages/core/src/skills/validate.ts new file mode 100644 index 0000000..37b0a11 --- /dev/null +++ b/packages/core/src/skills/validate.ts @@ -0,0 +1,66 @@ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; + +/** + * Skill validity check (aligned with vercel-labs/skills parseSkillMd semantics): + * 1. SKILL.md exists as a regular file at the directory root + * 2. frontmatter is valid YAML delimited by `---` + * 3. name / description fields exist and are non-empty strings + * + * Validation happens in the temp dir before writing to canonical — any failure rolls back the entire install. + */ +export interface SkillMeta { + name: string; + description: string; +} + +function fail(skillName: string, reason: string): never { + throw new BailianError( + `Skill ${skillName} validation failed: ${reason}`, + ExitCode.GENERAL, + "This skill package does not conform to the SKILL.md spec; contact the skill publisher to fix and republish", + ); +} + +function extractFrontmatter(content: string): string | null { + if (!content.startsWith("---")) return null; + const match = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(content); + return match ? match[1] : null; +} + +export function validateSkillDir(dir: string, skillName: string): SkillMeta { + const skillMdPath = join(dir, "SKILL.md"); + let raw: string; + try { + if (!statSync(skillMdPath).isFile()) fail(skillName, "SKILL.md is not a regular file"); + raw = readFileSync(skillMdPath, "utf-8"); + } catch (err) { + if (err instanceof BailianError) throw err; + fail(skillName, "missing SKILL.md"); + } + + const frontmatter = extractFrontmatter(raw); + if (frontmatter === null) + fail(skillName, "SKILL.md is missing frontmatter (--- delimited YAML header)"); + + let data: unknown; + try { + data = parse(frontmatter); + } catch { + fail(skillName, "frontmatter is not valid YAML"); + } + if (typeof data !== "object" || data === null) { + fail(skillName, "frontmatter is not a key-value structure"); + } + + const record = data as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + if (!name || !description) + fail(skillName, "frontmatter is missing non-empty name / description fields"); + + return { name, description }; +} diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 3fa0092..c6c00ec 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -112,12 +112,26 @@ export interface StreamChunk { export interface DashScopeImageRequest { model: string; - input: { - messages: Array<{ - role: "user"; - content: Array<{ text?: string; image?: string }>; - }>; - }; + input: + | { + messages: Array<{ + role: "user"; + content: Array<{ text?: string; image?: string }>; + }>; + } + | { + prompt: string; + /** Required by image2image models such as wan2.5-i2i-preview. */ + images?: string[]; + negative_prompt?: string; + } + | { + /** Required by wanx*-imageedit models. */ + function: string; + prompt: string; + base_image_url: string; + mask_image_url?: string; + }; parameters?: { size?: string; n?: number; @@ -125,6 +139,7 @@ export interface DashScopeImageRequest { prompt_extend?: boolean; watermark?: boolean; negative_prompt?: string; + strength?: number; }; } diff --git a/packages/core/src/utils/concurrency.ts b/packages/core/src/utils/concurrency.ts new file mode 100644 index 0000000..40a68ed --- /dev/null +++ b/packages/core/src/utils/concurrency.ts @@ -0,0 +1,22 @@ +/** + * Run async task factories with a bounded concurrency pool. + * Returns results in the same order as the input tasks array. + */ +export async function runWithConcurrency( + tasks: Array<() => Promise>, + limit: number, +): Promise { + const results: T[] = []; + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < tasks.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await tasks[currentIndex](); + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()); + await Promise.all(workers); + return results; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 4d28595..38943a0 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -3,6 +3,7 @@ export { resolveOutputDir } from "./output-dir.ts"; export { maskToken } from "./token.ts"; export { stripUndefined } from "./object.ts"; export { readTextFromPathOrStdin } from "./fs.ts"; +export { runWithConcurrency } from "./concurrency.ts"; export { parseBooleanValue, parseOptionalBooleanValue, diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 726513c..1aa52c4 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -32,7 +32,7 @@ const resolve = (s: Parameters[0]): Settings => buildSettings(src(s) test("token-plan Profile 预设保持固定", () => { expect(getModelProfilePreset("token-plan")).toEqual({ baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", - defaultTextModel: "qwen3.8-max-preview", + defaultTextModel: "qwen3.8-max", defaultVideoModel: "happyhorse-1.1-t2v", defaultImageToVideoModel: "happyhorse-1.1-i2v", defaultReferenceToVideoModel: "happyhorse-1.1-r2v", diff --git a/packages/core/tests/image-routes.test.ts b/packages/core/tests/image-routes.test.ts new file mode 100644 index 0000000..73e27f8 --- /dev/null +++ b/packages/core/tests/image-routes.test.ts @@ -0,0 +1,145 @@ +import { expect, test } from "vite-plus/test"; +import { + isLegacyImage2ImageModel, + isLegacyText2ImageModel, + isSyncMultimodalImageModel, + isWanxFunctionImageEditModel, + resolveImageEditApi, + resolveImageGenerateApi, + resolveImageSizeProfile, + resolvePromptExtendDefault, +} from "../src/client/image-routes.ts"; + +test("sync multimodal family covers qwen-image, wan2.6/2.7 image, and z-image", () => { + expect(isSyncMultimodalImageModel("qwen-image-2.0")).toBe(true); + expect(isSyncMultimodalImageModel("qwen-image-2.0-pro")).toBe(true); + expect(isSyncMultimodalImageModel("qwen-image-plus")).toBe(true); + expect(isSyncMultimodalImageModel("qwen-image-max")).toBe(true); + expect(isSyncMultimodalImageModel("wan2.7-image")).toBe(true); + expect(isSyncMultimodalImageModel("wan2.6-image")).toBe(true); + expect(isSyncMultimodalImageModel("z-image-turbo")).toBe(true); + expect(isSyncMultimodalImageModel("wan2.6-t2i")).toBe(false); +}); + +test("legacy text2image covers wan2.5/2.2/2.1 t2i and wanx but not wan2.6-t2i/image", () => { + expect(isLegacyText2ImageModel("wan2.2-t2i-plus")).toBe(true); + expect(isLegacyText2ImageModel("wan2.5-t2i-preview")).toBe(true); + expect(isLegacyText2ImageModel("wan2.1-t2i-turbo")).toBe(true); + expect(isLegacyText2ImageModel("wanx2.0-t2i-turbo")).toBe(true); + expect(isLegacyText2ImageModel("wanx-v1")).toBe(true); + expect(isLegacyText2ImageModel("wanx-v1-0521")).toBe(true); + expect(isLegacyText2ImageModel("wan2.6-t2i")).toBe(false); + expect(isLegacyText2ImageModel("wan2.6-image")).toBe(false); + expect(isLegacyText2ImageModel("wan2.7-image")).toBe(false); +}); + +test("legacy image2image is wan2.5-i2i only; wanx imageedit uses function protocol", () => { + expect(isLegacyImage2ImageModel("wan2.5-i2i-preview")).toBe(true); + expect(isLegacyImage2ImageModel("wanx2.1-imageedit")).toBe(false); + expect(isWanxFunctionImageEditModel("wanx2.1-imageedit")).toBe(true); + expect(isWanxFunctionImageEditModel("wan2.5-i2i-preview")).toBe(false); +}); + +test("size profiles are model-specific, not sync/async", () => { + expect(resolveImageSizeProfile("qwen-image-2.0")).toBe("qwen-image-2.0"); + expect(resolveImageSizeProfile("qwen-image")).toBe("qwen-image-fixed"); + expect(resolveImageSizeProfile("qwen-image-plus")).toBe("qwen-image-fixed"); + expect(resolveImageSizeProfile("qwen-image-max")).toBe("qwen-image-fixed"); + expect(resolveImageSizeProfile("wan2.7-image")).toBe("wan27"); + expect(resolveImageSizeProfile("z-image-turbo")).toBe("z-image"); + expect(resolveImageSizeProfile("wan2.6-t2i")).toBe("wan26"); + expect(resolveImageSizeProfile("wanx-v1")).toBe("wanx-v1"); + expect(resolveImageSizeProfile("wanx-v1-0521")).toBe("wanx-v1"); + expect(resolveImageSizeProfile("wan2.5-i2i-preview")).toBe("wan25-i2i"); + expect(resolveImageSizeProfile("wan2.2-t2i-plus")).toBe("wan-legacy"); +}); + +test("prompt_extend defaults follow model docs", () => { + expect(resolvePromptExtendDefault("qwen-image-2.0")).toBe(true); + expect(resolvePromptExtendDefault("qwen-image-max")).toBe(true); + expect(resolvePromptExtendDefault("z-image-turbo")).toBe(false); + expect(resolvePromptExtendDefault("wan2.7-image")).toBeUndefined(); + expect(resolvePromptExtendDefault("qwen-image-plus")).toBeUndefined(); +}); + +test("resolveImageGenerateApi picks path, input style, and size profile", () => { + expect(resolveImageGenerateApi("wanx2.0-t2i-turbo")).toMatchObject({ + kind: "async-text2image", + path: "/api/v1/services/aigc/text2image/image-synthesis", + inputStyle: "prompt", + useSync: false, + sizeProfile: "wan-legacy", + }); + expect(resolveImageGenerateApi("wanx-v1")).toMatchObject({ + sizeProfile: "wanx-v1", + inputStyle: "prompt", + }); + expect(resolveImageGenerateApi("wanx-v1-0521")).toMatchObject({ + kind: "async-text2image", + path: "/api/v1/services/aigc/text2image/image-synthesis", + inputStyle: "prompt", + useSync: false, + sizeProfile: "wanx-v1", + }); + expect(resolveImageGenerateApi("wan2.6-t2i")).toMatchObject({ + kind: "async-image-generation", + sizeProfile: "wan26", + }); + expect(resolveImageGenerateApi("qwen-image-2.0")).toMatchObject({ + kind: "sync-multimodal", + sizeProfile: "qwen-image-2.0", + promptExtendDefault: true, + }); + expect(resolveImageGenerateApi("qwen-image-plus")).toMatchObject({ + kind: "sync-multimodal", + sizeProfile: "qwen-image-fixed", + }); + expect(resolveImageGenerateApi("qwen-image")).toMatchObject({ + kind: "sync-multimodal", + sizeProfile: "qwen-image-fixed", + }); + expect(resolveImageGenerateApi("z-image-turbo")).toMatchObject({ + kind: "sync-multimodal", + sizeProfile: "z-image", + promptExtendDefault: false, + }); +}); + +test("resolveImageEditApi excludes pure T2I models from sync edit", () => { + expect(resolveImageEditApi("wan2.7-image")).toMatchObject({ + kind: "sync-multimodal", + inputStyle: "messages", + useSync: true, + }); + expect(resolveImageEditApi("wan2.6-image")).toMatchObject({ + kind: "sync-multimodal", + useSync: true, + }); + expect(resolveImageEditApi("qwen-image-2.0")).toMatchObject({ + kind: "sync-multimodal", + useSync: true, + }); + // Pure T2I models fall through to async image-generation, not sync edit. + expect(resolveImageEditApi("z-image-turbo")).toMatchObject({ + kind: "async-image-generation", + useSync: false, + }); + expect(resolveImageEditApi("qwen-image-plus")).toMatchObject({ + kind: "async-image-generation", + useSync: false, + }); + expect(resolveImageEditApi("qwen-image-max")).toMatchObject({ + kind: "async-image-generation", + useSync: false, + }); + expect(resolveImageEditApi("wan2.5-i2i-preview")).toMatchObject({ + kind: "async-image2image", + inputStyle: "prompt-images", + sizeProfile: "wan25-i2i", + }); + expect(resolveImageEditApi("wanx2.1-imageedit")).toMatchObject({ + kind: "async-image2image", + inputStyle: "function-base-image", + path: "/api/v1/services/aigc/image2image/image-synthesis", + }); +}); diff --git a/packages/core/tests/skills-agents.test.ts b/packages/core/tests/skills-agents.test.ts new file mode 100644 index 0000000..eee877a --- /dev/null +++ b/packages/core/tests/skills-agents.test.ts @@ -0,0 +1,129 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { expect, test } from "vite-plus/test"; +import { + detectInstalledAgents, + getAgentTargets, + linkSkillToAgents, + unlinkSkillFromAgents, +} from "../src/skills/agents.ts"; +import { getSkillsDir } from "../src/skills/lock.ts"; + +/** + * Isolated environment: HOME/XDG_CONFIG_HOME/BAILIAN_CONFIG_DIR all point to a temp dir, + * so agent detection and the canonical dir never touch the real home. + */ +async function inFakeHome(fn: (home: string) => Promise): Promise { + const saved = { + HOME: process.env.HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, + }; + const home = mkdtempSync(join(tmpdir(), "bl-skill-agents-")); + process.env.HOME = home; + process.env.XDG_CONFIG_HOME = join(home, ".config"); + process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian"); + try { + await fn(home); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(home, { recursive: true, force: true }); + } +} + +/** Create an installed skill in canonical */ +function seedCanonicalSkill(name: string): string { + const dir = join(getSkillsDir(), name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), "---\nname: x\ndescription: y\n---\n"); + return dir; +} + +test("agents: registry has universal + 11 agents, only detects those whose config dir exists", async () => { + await inFakeHome(async (home) => { + expect(getAgentTargets().map((a) => a.id)).toContain("universal"); + expect(getAgentTargets()).toHaveLength(11); + expect(detectInstalledAgents()).toEqual([]); + + mkdirSync(join(home, ".claude"), { recursive: true }); + mkdirSync(join(home, ".qoder"), { recursive: true }); + expect(detectInstalledAgents().map((a) => a.id)).toEqual(["claude-code", "qoder"]); + + // Cline config dir exists → hits the universal pseudo-agent + mkdirSync(join(home, ".cline"), { recursive: true }); + expect(detectInstalledAgents().map((a) => a.id)).toEqual(["universal", "claude-code", "qoder"]); + }); +}); + +test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude"), { recursive: true }); + const target = seedCanonicalSkill("demo"); + + const results = linkSkillToAgents("demo"); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ agent: "claude-code", mode: "symlink" }); + + const linkPath = join(home, ".claude", "skills", "demo"); + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(linkPath)).toBe(target); + // Real content is readable through the link + expect(readFileSync(join(linkPath, "SKILL.md"), "utf-8")).toContain("name: x"); + // Uninstalled agent dir was not created out of thin air + expect(existsSync(join(home, ".cursor"))).toBe(false); + }); +}); + +test("agents: existing unmanaged dir is skipped; managed stale link is rebuilt", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude"), { recursive: true }); + seedCanonicalSkill("demo"); + + // Real dir placed by the user → skipped, not cleared + const foreign = join(home, ".claude", "skills", "demo"); + mkdirSync(foreign, { recursive: true }); + writeFileSync(join(foreign, "user.txt"), "mine"); + const first = linkSkillToAgents("demo"); + expect(first[0].mode).toBe("skipped"); + expect(readFileSync(join(foreign, "user.txt"), "utf-8")).toBe("mine"); + + // Replace with our own stale link → rebuilt successfully + rmSync(foreign, { recursive: true, force: true }); + const again = linkSkillToAgents("demo"); + expect(again[0].mode).toBe("symlink"); + const rebuilt = linkSkillToAgents("demo"); + expect(rebuilt[0].mode).toBe("symlink"); + }); +}); + +test("agents: unlink reclaims managed links, leaves foreign content untouched", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude"), { recursive: true }); + mkdirSync(join(home, ".agents"), { recursive: true }); + seedCanonicalSkill("demo"); + const links = linkSkillToAgents("demo"); + expect(links.filter((l) => l.mode === "symlink")).toHaveLength(2); + + // Foreign file with the same name placed in cursor (should be unaffected even if not detected) + const removed = unlinkSkillFromAgents( + "demo", + links.map((l) => l.path), + ); + expect(removed.sort()).toEqual(links.map((l) => l.path).sort()); + expect(existsSync(join(home, ".claude", "skills", "demo"))).toBe(false); + expect(existsSync(join(home, ".agents", "skills", "demo"))).toBe(false); + }); +}); diff --git a/packages/core/tests/skills-installer.test.ts b/packages/core/tests/skills-installer.test.ts new file mode 100644 index 0000000..63dd3d0 --- /dev/null +++ b/packages/core/tests/skills-installer.test.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs"; +import { createHash } from "crypto"; +import { tmpdir } from "os"; +import { join } from "path"; +import { brotliCompressSync } from "zlib"; +import tar from "tar-stream"; +import { expect, test } from "vite-plus/test"; +import { BailianError } from "../src/errors/base.ts"; +import { installSkillFromBuffer } from "../src/skills/installer.ts"; +import { getSkillsDir } from "../src/skills/lock.ts"; + +/** Run in an isolated temp config dir, restore env afterwards. */ +async function inTempConfigDir(fn: () => Promise): Promise { + const saved = process.env.BAILIAN_CONFIG_DIR; + const dir = mkdtempSync(join(tmpdir(), "bl-skill-install-")); + process.env.BAILIAN_CONFIG_DIR = dir; + try { + await fn(); + } finally { + if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = saved; + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Build a skill archive the same way as the publisher (tar.pack + brotli) */ +async function buildTarBr(files: Record): Promise { + const pack = tar.pack(); + const chunks: Buffer[] = []; + pack.on("data", (chunk: Buffer) => chunks.push(chunk)); + const done = new Promise((resolvePromise, reject) => { + pack.on("end", resolvePromise); + pack.on("error", reject); + }); + for (const [rel, content] of Object.entries(files)) { + pack.entry({ name: rel }, content); + } + pack.finalize(); + await done; + return brotliCompressSync(Buffer.concat(chunks)); +} + +const VALID_SKILL_MD = "---\nname: demo\ndescription: demo skill\n---\n\n# Demo v1\n"; + +test("installer: valid archive installs to canonical and returns metadata", async () => { + await inTempConfigDir(async () => { + const buf = await buildTarBr({ + "SKILL.md": VALID_SKILL_MD, + "references/usage.md": "# usage\n", + }); + const installed = await installSkillFromBuffer("demo", buf); + expect(installed).toMatchObject({ + name: "demo", + meta: { name: "demo", description: "demo skill" }, + }); + expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD); + expect(existsSync(join(getSkillsDir(), "demo", "references", "usage.md"))).toBe(true); + // No temp/backup dirs left behind + expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]); + }); +}); + +test("installer: reinstall atomically swaps, no old files left behind", async () => { + await inTempConfigDir(async () => { + await installSkillFromBuffer( + "demo", + await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "old-only.md": "v1\n" }), + ); + const v2 = "---\nname: demo\ndescription: demo skill v2\n---\n"; + await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": v2 })); + expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(v2); + expect(existsSync(join(getSkillsDir(), "demo", "old-only.md"))).toBe(false); + }); +}); + +test("installer: tar-slip entry → rejected and canonical not written", async () => { + await inTempConfigDir(async () => { + const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "../evil.txt": "pwned\n" }); + await expect(installSkillFromBuffer("demo", buf)).rejects.toThrow(/unsafe tar entry/); + expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false); + expect(existsSync(join(process.env.BAILIAN_CONFIG_DIR!, "evil.txt"))).toBe(false); + }); +}); + +test("installer: SKILL.md validation fails → previously installed version preserved as-is", async () => { + await inTempConfigDir(async () => { + await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD })); + const bad = await buildTarBr({ "README.md": "no skill md\n" }); + await expect(installSkillFromBuffer("demo", bad)).rejects.toThrow(BailianError); + // Old version untouched, temp dir cleaned up + expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD); + expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]); + }); +}); + +test("installer: invalid skill name rejected outright", async () => { + await inTempConfigDir(async () => { + const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD }); + await expect(installSkillFromBuffer("../escape", buf)).rejects.toThrow(/Invalid skill name/); + }); +}); + +/** Same accumulation as publisher computeContentHash: sorted rel path + bytes */ +function expectedHashOf(files: Record): string { + const hash = createHash("sha256"); + for (const rel of Object.keys(files).sort()) { + hash.update(rel); + hash.update(Buffer.from(files[rel])); + } + return `sha256:${hash.digest("hex")}`; +} + +test("installer: matching contentHash passes integrity check", async () => { + await inTempConfigDir(async () => { + const files = { "SKILL.md": VALID_SKILL_MD, "references/usage.md": "# usage\n" }; + const installed = await installSkillFromBuffer( + "demo", + await buildTarBr(files), + expectedHashOf(files), + ); + expect(installed.name).toBe("demo"); + expect(existsSync(join(getSkillsDir(), "demo", "SKILL.md"))).toBe(true); + }); +}); + +test("installer: contentHash mismatch → rejected, previous install preserved", async () => { + await inTempConfigDir(async () => { + await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD })); + const tampered = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "extra.md": "tampered\n" }); + await expect( + installSkillFromBuffer("demo", tampered, expectedHashOf({ "SKILL.md": VALID_SKILL_MD })), + ).rejects.toThrow(/integrity check/); + // Old version untouched, temp dir cleaned up + expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD); + expect(existsSync(join(getSkillsDir(), "demo", "extra.md"))).toBe(false); + expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]); + }); +}); diff --git a/packages/core/tests/skills-lock.test.ts b/packages/core/tests/skills-lock.test.ts new file mode 100644 index 0000000..4818e1c --- /dev/null +++ b/packages/core/tests/skills-lock.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { expect, test } from "vite-plus/test"; +import { + emptySkillLock, + getSkillLockPath, + getSkillsDir, + readSkillLock, + upsertSkillLockEntry, + writeSkillLock, +} from "../src/skills/lock.ts"; + +/** Run in an isolated temp config dir, restore env afterwards. */ +async function inTempConfigDir(fn: () => Promise): Promise { + const saved = process.env.BAILIAN_CONFIG_DIR; + const dir = mkdtempSync(join(tmpdir(), "bl-skill-lock-")); + process.env.BAILIAN_CONFIG_DIR = dir; + try { + await fn(); + } finally { + if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = saved; + rmSync(dir, { recursive: true, force: true }); + } +} + +test("skill-lock: paths follow BAILIAN_CONFIG_DIR", async () => { + await inTempConfigDir(async () => { + expect(getSkillsDir()).toBe(join(process.env.BAILIAN_CONFIG_DIR!, "skills")); + expect(getSkillLockPath()).toBe(join(getSkillsDir(), "skill-lock.json")); + }); +}); + +test("skill-lock: first install (file absent) returns empty table, not an error", async () => { + await inTempConfigDir(async () => { + expect(readSkillLock()).toEqual(emptySkillLock()); + }); +}); + +test("skill-lock: written data reads back with links/sourceType", async () => { + await inTempConfigDir(async () => { + const lock = emptySkillLock(); + lock.skills["spark-video"] = { + contentHash: "sha256:abc", + publishedAt: "2026-07-23T00:00:00+08:00", + installedAt: "2026-07-23T00:00:00Z", + sourceType: "oss", + links: ["/tmp/x/.claude/skills/spark-video"], + }; + writeSkillLock(lock); + expect(readSkillLock()).toEqual(lock); + }); +}); + +test("skill-lock: corrupted JSON / unrecognized version → treated as empty table", async () => { + await inTempConfigDir(async () => { + mkdirSync(getSkillsDir(), { recursive: true }); + writeFileSync(getSkillLockPath(), "{ not json"); + expect(readSkillLock()).toEqual(emptySkillLock()); + + writeFileSync(getSkillLockPath(), JSON.stringify({ version: 99, skills: {} })); + expect(readSkillLock()).toEqual(emptySkillLock()); + + writeFileSync(getSkillLockPath(), JSON.stringify({ version: 1 })); + expect(readSkillLock()).toEqual(emptySkillLock()); + }); +}); + +test("skill-lock: upsert shallow-merge — silent sync channel does not overwrite links written by add", async () => { + await inTempConfigDir(async () => { + // upsert on empty table = create entry (postinstall first-time bookkeeping scenario) + upsertSkillLockEntry("bailian-docs-llm-wiki", { + contentHash: "sha256:v1", + installedAt: "2026-07-23T00:00:00Z", + sourceType: "oss", + }); + expect(readSkillLock().skills["bailian-docs-llm-wiki"].contentHash).toBe("sha256:v1"); + + // After bl skill add adds links, advisor sync only updates the fingerprint → links preserved + upsertSkillLockEntry("bailian-docs-llm-wiki", { + contentHash: "sha256:v1", + installedAt: "2026-07-23T01:00:00Z", + sourceType: "oss", + links: ["/tmp/x/.claude/skills/bailian-docs-llm-wiki"], + }); + upsertSkillLockEntry("bailian-docs-llm-wiki", { + contentHash: "sha256:v2", + installedAt: "2026-07-24T00:00:00Z", + sourceType: "oss", + }); + const entry = readSkillLock().skills["bailian-docs-llm-wiki"]; + expect(entry.contentHash).toBe("sha256:v2"); + expect(entry.links).toEqual(["/tmp/x/.claude/skills/bailian-docs-llm-wiki"]); + // Other skills' entries are unaffected + upsertSkillLockEntry("other", { + contentHash: "sha256:v9", + installedAt: "2026-07-24T00:00:00Z", + sourceType: "oss", + }); + expect(readSkillLock().skills["bailian-docs-llm-wiki"].contentHash).toBe("sha256:v2"); + }); +}); diff --git a/packages/core/tests/skills-status.test.ts b/packages/core/tests/skills-status.test.ts new file mode 100644 index 0000000..c84cec8 --- /dev/null +++ b/packages/core/tests/skills-status.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "vite-plus/test"; +import { sanitizeSkillName, isSafeSkillName } from "../src/skills/sanitize.ts"; +import { computeSkillStatuses } from "../src/skills/status.ts"; +import type { SkillLockFile, SkillsIndex } from "../src/skills/types.ts"; + +const PUB = "2026-07-23T00:00:00+08:00"; + +function makeIndex(skills: Record): SkillsIndex { + return { + skills: Object.fromEntries( + Object.entries(skills).map(([name, contentHash]) => [ + name, + { contentHash, publishedAt: PUB }, + ]), + ), + }; +} + +function makeLock(skills: Record): SkillLockFile { + return { + version: 1, + skills: Object.fromEntries( + Object.entries(skills).map(([name, contentHash]) => [ + name, + { contentHash, installedAt: "2026-07-23T00:00:00Z", sourceType: "oss" as const }, + ]), + ), + }; +} + +test("status: five-state derivation matrix", () => { + const index = makeIndex({ a: "h-a2", b: "h-b", c: "h-c", d: "h-d", e: "h-e" }); + const lock = makeLock({ a: "h-a1", b: "h-b", e: "h-e", zombie: "h-z" }); + // a: lock h-a1 / remote h-a2 / on disk → outdated + // b: lock h-b / remote h-b / on disk → installed + // c: no lock / on disk (synced by other channel) → untracked + // d: no lock / not on disk → not-installed + // e: lock h-e / dir deleted → missing + // zombie: in lock / delisted from remote / on disk → installed (retained locally) + // stray: on disk / in neither lock nor remote → untracked + const rows = computeSkillStatuses(index, lock, ["a", "b", "c", "zombie", "stray"]); + const byName = Object.fromEntries(rows.map((r) => [r.name, r])); + expect(byName.a.status).toBe("outdated"); + expect(byName.a.publishedAt).toBe(PUB); + expect(byName.b.status).toBe("installed"); + expect(byName.c.status).toBe("untracked"); + expect(byName.c.publishedAt).toBe(PUB); + expect(byName.d.status).toBe("not-installed"); + expect(byName.e.status).toBe("missing"); + expect(byName.zombie.status).toBe("installed"); + expect(byName.zombie.publishedAt).toBeUndefined(); + expect(byName.stray.status).toBe("untracked"); + expect(rows.map((r) => r.name)).toEqual(rows.map((r) => r.name).sort()); +}); + +test("status: first use (empty lock + empty disk) → all not-installed", () => { + const rows = computeSkillStatuses(makeIndex({ a: "1", b: "2" }), makeLock({}), []); + expect(rows.every((r) => r.status === "not-installed")).toBe(true); +}); + +test("status: empty remote registry + nothing local → empty list", () => { + expect(computeSkillStatuses(makeIndex({}), makeLock({}), [])).toEqual([]); +}); + +test("sanitize: path traversal/illegal chars sanitized, safe names unchanged", () => { + expect(sanitizeSkillName("../../.ssh")).toBe("ssh"); + expect(sanitizeSkillName("My Cool Skill!!")).toBe("My-Cool-Skill!!"); + expect(sanitizeSkillName("a/b\\c:d")).toBe("a-b-c-d"); + expect(sanitizeSkillName("...")).toBe("unnamed-skill"); + expect(isSafeSkillName("spark-video")).toBe(true); + expect(isSafeSkillName("bailian.model_v2")).toBe(true); + expect(isSafeSkillName("../evil")).toBe(false); + expect(isSafeSkillName("a b")).toBe(false); + expect(isSafeSkillName("")).toBe(false); +}); diff --git a/packages/core/tests/skills-validate.test.ts b/packages/core/tests/skills-validate.test.ts new file mode 100644 index 0000000..d22bfee --- /dev/null +++ b/packages/core/tests/skills-validate.test.ts @@ -0,0 +1,78 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { expect, test } from "vite-plus/test"; +import { BailianError } from "../src/errors/base.ts"; +import { validateSkillDir } from "../src/skills/validate.ts"; + +function withSkillDir(fn: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "bl-skill-validate-")); + try { + fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function expectFail(dir: string, reasonPart: string): void { + try { + validateSkillDir(dir, "demo"); + throw new Error("expected validateSkillDir to throw"); + } catch (err) { + expect(err).toBeInstanceOf(BailianError); + expect((err as BailianError).message).toContain(reasonPart); + } +} + +test("validate: valid SKILL.md passes and returns frontmatter metadata", () => { + withSkillDir((dir) => { + writeFileSync( + join(dir, "SKILL.md"), + "---\nname: demo-skill\ndescription: a demo skill\n---\n\n# Demo\n", + ); + expect(validateSkillDir(dir, "demo")).toEqual({ + name: "demo-skill", + description: "a demo skill", + }); + }); +}); + +test("validate: missing SKILL.md → rejected", () => { + withSkillDir((dir) => expectFail(dir, "missing SKILL.md")); +}); + +test("validate: SKILL.md is a directory → rejected", () => { + withSkillDir((dir) => { + mkdirSync(join(dir, "SKILL.md")); + expectFail(dir, "SKILL.md is not a regular file"); + }); +}); + +test("validate: missing frontmatter → rejected", () => { + withSkillDir((dir) => { + writeFileSync(join(dir, "SKILL.md"), "# no frontmatter\n"); + expectFail(dir, "missing frontmatter"); + }); +}); + +test("validate: frontmatter invalid YAML → rejected", () => { + withSkillDir((dir) => { + writeFileSync(join(dir, "SKILL.md"), "---\nname: [unclosed\n---\nbody\n"); + expectFail(dir, "not valid YAML"); + }); +}); + +test("validate: name/description missing or empty → rejected", () => { + withSkillDir((dir) => { + writeFileSync(join(dir, "SKILL.md"), "---\nname: demo\n---\nbody\n"); + expectFail(dir, "name / description"); + }); + withSkillDir((dir) => { + writeFileSync(join(dir, "SKILL.md"), '---\nname: demo\ndescription: " "\n---\nbody\n'); + expectFail(dir, "name / description"); + }); + withSkillDir((dir) => { + writeFileSync(join(dir, "SKILL.md"), "---\nname: demo\ndescription: 123\n---\nbody\n"); + expectFail(dir, "name / description"); + }); +}); diff --git a/packages/kscli/package.json b/packages/kscli/package.json index 6c440db..7ce490c 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "1.10.1", + "version": "1.13.1", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index be91662..d20c877 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "1.10.1", + "version": "1.13.1", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4db579c..d8cadb9 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -34,11 +34,13 @@ export { BAILIAN_CONSOLE, API_KEY_PAGE, TOKEN_PLAN_PAGE, + MCP_WEBSEARCH_PAGE, + mcpMarketplaceDetailPage, VOICE_TTS_PAGE, } from "./urls.ts"; // Output facilities consumed by commands -export { emitResult, emitBare } from "./output/output.ts"; +export { emitResult, emitBare, emitRequestId } from "./output/output.ts"; export { formatTable } from "./output/table.ts"; export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts"; export { createSpinner, createProgressBar } from "./output/progress.ts"; diff --git a/packages/runtime/src/output/output.ts b/packages/runtime/src/output/output.ts index 04c3037..9b6707f 100644 --- a/packages/runtime/src/output/output.ts +++ b/packages/runtime/src/output/output.ts @@ -16,3 +16,15 @@ export function emitResult(data: unknown, format: OutputFormat): void { export function emitBare(value: string): void { process.stdout.write(value + "\n"); } + +/** + * Surface a server request id for text-mode output. Written to stderr (the + * diagnostic channel) so it never corrupts the primary stdout result, mirroring + * the request_id line the verbose HTTP logger prints. No-op when the id is + * absent (e.g. dry-run) or in --quiet mode (which owes callers a bare scalar). + * JSON output surfaces request_id inside the payload instead of calling this. + */ +export function emitRequestId(requestId: string | undefined, quiet: boolean): void { + if (!requestId || quiet) return; + process.stderr.write(`request_id: ${requestId}\n`); +} diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index 48e32f6..7c6987e 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -4,8 +4,8 @@ */ import { chatPath, - imagePath, - imageSyncPath, + resolveImageEditApi, + resolveImageGenerateApi, videoGeneratePath, taskPath, speechSynthesizePath, @@ -58,7 +58,7 @@ export async function textChat( }); } - const model = input.model || "qwen3.7-max"; + const model = input.model || "qwen3.8-max"; const messages: Array<{ role: string; content: string }> = []; if (input.system) messages.push({ role: "system", content: input.system }); messages.push({ role: "user", content: input.message }); @@ -147,12 +147,6 @@ export async function visionDescribe( // --- image/generate --- -const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"]; - -function isSyncImageModel(model: string): boolean { - return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p)); -} - export interface ImageGenerateInput { prompt?: string; model?: string; @@ -178,61 +172,72 @@ export async function imageGenerate( } const model = input.model || "qwen-image-2.0"; - const useSync = isSyncImageModel(model); + const route = resolveImageGenerateApi(model); const n = input.n ?? 1; const promptExtend = resolveBooleanFlag( input["prompt-extend"], - useSync ? true : undefined, + route.promptExtendDefault, "prompt-extend", ); - const body: DashScopeImageRequest = { - model, - input: { - messages: [{ role: "user", content: [{ text: input.prompt }] }], - }, - parameters: { - size: resolveImageSize(input.size, useSync), - n, - seed: input.seed, - prompt_extend: promptExtend, - watermark: resolveWatermark(input.watermark), - negative_prompt: input["negative-prompt"] || undefined, - }, + const parameters: NonNullable = { + size: resolveImageSize(input.size, route.sizeProfile), + n, + seed: input.seed, + prompt_extend: promptExtend, + watermark: resolveWatermark(input.watermark), }; - if (useSync) { - const url = imageSyncPath(); + const body: DashScopeImageRequest = + route.inputStyle === "prompt" + ? { + model, + input: { + prompt: input.prompt, + negative_prompt: input["negative-prompt"] || undefined, + }, + parameters, + } + : { + model, + input: { + messages: [{ role: "user", content: [{ text: input.prompt }] }], + }, + parameters: { + ...parameters, + negative_prompt: input["negative-prompt"] || undefined, + }, + }; + + if (route.useSync) { const response = await env.client.requestJson({ - path: url, + path: route.path, method: "POST", body, signal: ctx.signal, }); const urls = response.output.choices - .flatMap((c) => c.message?.content || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; - } else { - // Async mode: submit then poll - const url = imagePath(); - const asyncResp = await env.client.requestJson({ - path: url, - method: "POST", - body, - async: true, - signal: ctx.signal, - }); - const taskId = asyncResp.output.task_id; - const result = await pollTask(env, taskId, ctx); - const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; - const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); - if (saved) result.saved = saved; - return result; } + + const asyncResp = await env.client.requestJson({ + path: route.path, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + const result = await pollTask(env, taskId, ctx); + const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + if (saved) result.saved = saved; + return result; } // --- image/edit --- @@ -247,6 +252,7 @@ export interface ImageEditInput { "negative-prompt"?: string; "prompt-extend"?: boolean | string; watermark?: boolean | string; + function?: string; "out-dir"?: string; "out-prefix"?: string; } @@ -264,70 +270,106 @@ export async function imageEdit( const images = Array.isArray(input.image) ? input.image : input.image ? [input.image] : []; const model = input.model || "qwen-image-2.0"; - const useSync = isSyncImageModel(model); + const route = resolveImageEditApi(model); const n = input.n ?? 1; const promptExtend = resolveBooleanFlag( input["prompt-extend"], - useSync ? true : undefined, + route.promptExtendDefault, "prompt-extend", ); - const content: Array<{ text?: string; image?: string }> = []; - for (const img of images) { - let imageUrl = img; - if (isLocalFile(img)) { - imageUrl = await env.client.uploadFile(img, model, { signal: ctx.signal }); + const resolvedImages: string[] = []; + for (const image of images) { + let imageUrl = image; + if (isLocalFile(image)) { + imageUrl = await env.client.uploadFile(image, model, { signal: ctx.signal }); } - content.push({ image: imageUrl }); + resolvedImages.push(imageUrl); } - content.push({ text: input.prompt }); - const body: DashScopeImageRequest = { - model, - input: { - messages: [{ role: "user", content }], - }, - parameters: { - size: resolveImageSize(input.size, useSync), - n, - seed: input.seed, - prompt_extend: promptExtend, - watermark: resolveWatermark(input.watermark), - negative_prompt: input["negative-prompt"] || undefined, - }, + const parameters: NonNullable = { + size: resolveImageSize(input.size, route.sizeProfile), + n, + seed: input.seed, + prompt_extend: promptExtend, + watermark: resolveWatermark(input.watermark), }; - if (useSync) { - const url = imageSyncPath(); + let body: DashScopeImageRequest; + if (route.inputStyle === "function-base-image") { + const baseImageUrl = resolvedImages[0]; + if (!baseImageUrl) { + throw new PipelineError( + "missing_input", + "image/edit with wanx*-imageedit requires at least one image", + { step: "image/edit" }, + ); + } + body = { + model, + input: { + function: input.function || "description_edit", + prompt: input.prompt, + base_image_url: baseImageUrl, + }, + parameters, + }; + } else if (route.inputStyle === "prompt-images") { + body = { + model, + input: { + prompt: input.prompt, + images: resolvedImages, + negative_prompt: input["negative-prompt"] || undefined, + }, + parameters, + }; + } else { + const content: Array<{ text?: string; image?: string }> = resolvedImages.map((imageUrl) => ({ + image: imageUrl, + })); + content.push({ text: input.prompt }); + body = { + model, + input: { + messages: [{ role: "user", content }], + }, + parameters: { + ...parameters, + negative_prompt: input["negative-prompt"] || undefined, + }, + }; + } + + if (route.useSync) { const response = await env.client.requestJson({ - path: url, + path: route.path, method: "POST", body, signal: ctx.signal, }); const urls = response.output.choices - .flatMap((c) => c.message?.content || []) + .flatMap((choice) => choice.message?.content || []) .map((item) => item.image) .filter(Boolean); const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); return { urls, request_id: response.request_id, ...(saved ? { saved } : {}) }; - } else { - const url = imagePath(); - const asyncResp = await env.client.requestJson({ - path: url, - method: "POST", - body, - async: true, - signal: ctx.signal, - }); - const taskId = asyncResp.output.task_id; - const result = await pollTask(env, taskId, ctx); - const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; - const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); - if (saved) result.saved = saved; - return result; } + + const asyncResp = await env.client.requestJson({ + path: route.path, + method: "POST", + body, + async: true, + signal: ctx.signal, + }); + const taskId = asyncResp.output.task_id; + const result = await pollTask(env, taskId, ctx); + const urls = Array.isArray(result.urls) ? (result.urls as string[]) : []; + const saved = await maybeDownloadImages(urls, input["out-dir"], input["out-prefix"]); + if (saved) result.saved = saved; + return result; } /** diff --git a/packages/runtime/src/urls.ts b/packages/runtime/src/urls.ts index 60a3b33..16ada5c 100644 --- a/packages/runtime/src/urls.ts +++ b/packages/runtime/src/urls.ts @@ -18,5 +18,16 @@ export const API_KEY_PAGE = `${BAILIAN_CONSOLE}/?tab=app#/api-key`; /** Direct deep link to the Token Plan subscription overview and API key entry. */ export const TOKEN_PLAN_PAGE = `${BAILIAN_CONSOLE_ROOT}/cn-beijing?tab=plan#/efm/subscription/overview`; +/** MCP marketplace detail page for a server code (e.g. WebSearch, market-cmapi00073529). */ +export function mcpMarketplaceDetailPage(serverCode: string): string { + return `${BAILIAN_CONSOLE}?tab=mcp#/mcp-market/detail/${serverCode}`; +} + +/** + * MCP marketplace detail for the built-in WebSearch server. + * Users must activate (or re-activate for Streamable HTTP) before `search web` works. + */ +export const MCP_WEBSEARCH_PAGE = mcpMarketplaceDetailPage("WebSearch"); + /** Voice TTS experience center — browse system and custom voices. */ export const VOICE_TTS_PAGE = "https://help.aliyun.com/zh/model-studio/cosyvoice-voice-list"; diff --git a/packages/runtime/src/utils/image-size.ts b/packages/runtime/src/utils/image-size.ts index b986b5c..d2fd1f7 100644 --- a/packages/runtime/src/utils/image-size.ts +++ b/packages/runtime/src/utils/image-size.ts @@ -1,19 +1,15 @@ +import type { ImageSizeProfile } from "bailian-cli-core"; + /** - * Resolve image `size` flag for image generate/edit. + * Resolve image `--size` for generate/edit by model-family profile. * - * Users may pass either a ratio (e.g. "1:1", "3:4", "16:9") or a pixel size - * (e.g. "2048*2048"). The DashScope API only accepts the pixel format, so we - * map known ratios to the recommended pixel size for each model family. - * - * Sync models (qwen-image-2.0 / qwen-image-max / qwen-image-edit-2.0): - * higher-resolution presets. - * - * Async models (wanx2.x): smaller presets. - * - * Pixel-format input is passed through unchanged. + * Users may pass a ratio (e.g. "1:1") or pixels (e.g. "2048*2048"). + * Pixel input is passed through; ratios are mapped per model profile. + * Do not infer size from sync/async — that mismatches model constraints. */ -export const SYNC_RATIO_MAP: Record = { +/** qwen-image-2.0 / qwen-image-edit recommended high-res presets. */ +export const QWEN_IMAGE_20_RATIO_MAP: Record = { "16:9": "2688*1536", "9:16": "1536*2688", "1:1": "2048*2048", @@ -21,7 +17,8 @@ export const SYNC_RATIO_MAP: Record = { "3:4": "1728*2368", }; -export const ASYNC_RATIO_MAP: Record = { +/** qwen-image-plus / qwen-image-max fixed resolution presets. */ +export const QWEN_IMAGE_FIXED_RATIO_MAP: Record = { "16:9": "1664*928", "4:3": "1472*1104", "1:1": "1328*1328", @@ -29,11 +26,97 @@ export const ASYNC_RATIO_MAP: Record = { "9:16": "928*1664", }; -/** Resolve `--size` value: accept ratio (3:4) or pixel (W*H) format. */ +/** wan2.7-image* — default 2K square for 1:1. */ +export const WAN27_RATIO_MAP: Record = { + "16:9": "2688*1536", + "9:16": "1536*2688", + "1:1": "2048*2048", + "4:3": "2368*1728", + "3:4": "1728*2368", +}; + +/** z-image* recommended ~1K presets. */ +export const Z_IMAGE_RATIO_MAP: Record = { + "1:1": "1024*1024", + "16:9": "1280*720", + "9:16": "720*1280", + "4:3": "1152*864", + "3:4": "864*1152", + "3:2": "1248*832", + "2:3": "832*1248", +}; + +/** wan2.6-t2i / wan2.6-image / wan2.5-t2i — total pixels ≥ 1280*1280. */ +export const WAN26_RATIO_MAP: Record = { + "1:1": "1280*1280", + "16:9": "1696*960", + "9:16": "960*1696", + "4:3": "1472*1104", + "3:4": "1104*1472", +}; + +/** wan2.2 and earlier t2i / wanx*-t2i — 1024 class. */ +export const WAN_LEGACY_RATIO_MAP: Record = { + "1:1": "1024*1024", + "16:9": "1280*720", + "9:16": "720*1280", + "3:4": "768*1152", + "4:3": "1152*768", +}; + +/** wanx-v1 only documents these discrete sizes. */ +export const WANX_V1_RATIO_MAP: Record = { + "1:1": "1024*1024", + "9:16": "720*1280", + "3:4": "768*1152", + "16:9": "1280*720", +}; + +/** wan2.5-i2i — total pixels up to ~1280*1280. */ +export const WAN25_I2I_RATIO_MAP: Record = { + "1:1": "1280*1280", + "16:9": "1280*720", + "9:16": "720*1280", + "4:3": "1152*864", + "3:4": "864*1152", +}; + +/** @deprecated Prefer profile maps; kept for callers that still think in sync/async. */ +export const SYNC_RATIO_MAP = QWEN_IMAGE_20_RATIO_MAP; +/** @deprecated Prefer profile maps; kept as qwen-image-plus/max fixed presets. */ +export const ASYNC_RATIO_MAP = QWEN_IMAGE_FIXED_RATIO_MAP; + +const PROFILE_RATIO_MAPS: Record> = { + "qwen-image-2.0": QWEN_IMAGE_20_RATIO_MAP, + "qwen-image-fixed": QWEN_IMAGE_FIXED_RATIO_MAP, + wan27: WAN27_RATIO_MAP, + "z-image": Z_IMAGE_RATIO_MAP, + wan26: WAN26_RATIO_MAP, + "wan-legacy": WAN_LEGACY_RATIO_MAP, + "wanx-v1": WANX_V1_RATIO_MAP, + "wan25-i2i": WAN25_I2I_RATIO_MAP, +}; + +/** Resolve `--size` with an explicit model size profile. */ +export function resolveImageSize(input: string, sizeProfile: ImageSizeProfile): string; +export function resolveImageSize( + input: string | undefined, + sizeProfile: ImageSizeProfile, +): string | undefined; +/** @deprecated Prefer `ImageSizeProfile`; boolean maps sync→qwen-image-2.0 / async→qwen-image-fixed. */ export function resolveImageSize(input: string, useSync: boolean): string; export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined; -export function resolveImageSize(input: string | undefined, useSync: boolean): string | undefined { +export function resolveImageSize( + input: string | undefined, + sizeProfileOrUseSync: ImageSizeProfile | boolean, +): string | undefined { if (!input) return undefined; - const map = useSync ? SYNC_RATIO_MAP : ASYNC_RATIO_MAP; + const profile: ImageSizeProfile = + typeof sizeProfileOrUseSync === "boolean" + ? sizeProfileOrUseSync + ? "qwen-image-2.0" + : "qwen-image-fixed" + : sizeProfileOrUseSync; + const map = PROFILE_RATIO_MAPS[profile]; return map[input] ?? input; } diff --git a/packages/runtime/tests/image-size.test.ts b/packages/runtime/tests/image-size.test.ts new file mode 100644 index 0000000..d5ae183 --- /dev/null +++ b/packages/runtime/tests/image-size.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "vite-plus/test"; +import { resolveImageSize } from "../src/utils/image-size.ts"; + +test("qwen-image-plus fixed profile maps 1:1 to 1328*1328", () => { + expect(resolveImageSize("1:1", "qwen-image-fixed")).toBe("1328*1328"); + expect(resolveImageSize("16:9", "qwen-image-fixed")).toBe("1664*928"); +}); + +test("qwen-image-2.0 profile maps 1:1 to 2048*2048", () => { + expect(resolveImageSize("1:1", "qwen-image-2.0")).toBe("2048*2048"); +}); + +test("wanx-v1 profile maps 1:1 to 1024*1024", () => { + expect(resolveImageSize("1:1", "wanx-v1")).toBe("1024*1024"); + expect(resolveImageSize("16:9", "wanx-v1")).toBe("1280*720"); +}); + +test("wan26 profile maps 16:9 and 9:16 to ≥1280*1280 total pixels", () => { + expect(resolveImageSize("16:9", "wan26")).toBe("1696*960"); + expect(resolveImageSize("9:16", "wan26")).toBe("960*1696"); + expect(resolveImageSize("1:1", "wan26")).toBe("1280*1280"); +}); + +test("wan2.5-i2i profile maps 1:1 to 1280*1280", () => { + expect(resolveImageSize("1:1", "wan25-i2i")).toBe("1280*1280"); +}); + +test("z-image profile maps 1:1 to 1024*1024", () => { + expect(resolveImageSize("1:1", "z-image")).toBe("1024*1024"); +}); + +test("pixel sizes pass through unchanged", () => { + expect(resolveImageSize("1024*1024", "qwen-image-fixed")).toBe("1024*1024"); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e726653..eb698a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@types/node': specifier: ^24 version: 24.12.2 + '@types/tar-stream': + specifier: ^3.1.4 + version: 3.1.4 '@types/yauzl': specifier: ^3.4.0 version: 3.4.0 @@ -21,6 +24,9 @@ catalogs: chalk: specifier: ^5.6.2 version: 5.6.2 + tar-stream: + specifier: ^3.2.0 + version: 3.2.0 smol-toml: specifier: ^1.4.2 version: 1.7.0 @@ -66,6 +72,9 @@ importers: bailian-cli-runtime: specifier: workspace:* version: link:../runtime + tar-stream: + specifier: 'catalog:' + version: 3.2.0 devDependencies: '@clack/prompts': specifier: ^0.7.0 @@ -143,6 +152,9 @@ importers: packages/core: dependencies: + tar-stream: + specifier: 'catalog:' + version: 3.2.0 yaml: specifier: ^2.8.3 version: 2.8.3 @@ -153,6 +165,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.12.2 + '@types/tar-stream': + specifier: 'catalog:' + version: 3.1.4 '@types/yauzl': specifier: 'catalog:' version: 3.4.0 @@ -860,6 +875,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/tar-stream@3.1.4': + resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} + '@types/yauzl@3.4.0': resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} @@ -1070,6 +1088,51 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + boxen@8.0.1: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} @@ -1107,9 +1170,15 @@ packages: engines: {node: '>=18'} hasBin: true + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} @@ -1326,6 +1395,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1345,6 +1417,15 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1814,6 +1895,10 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/tar-stream@3.1.4': + dependencies: + '@types/node': 25.6.0 + '@types/yauzl@3.4.0': dependencies: '@types/node': 25.6.0 @@ -2057,6 +2142,37 @@ snapshots: assertion-error@2.0.1: {} + b4a@1.8.1: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.1.1 + boxen@8.0.1: dependencies: ansi-align: 3.0.1 @@ -2113,8 +2229,16 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-uri@3.1.2: {} fdir@6.5.0(picomatch@4.0.4): @@ -2334,6 +2458,15 @@ snapshots: std-env@4.1.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2358,6 +2491,30 @@ snapshots: dependencies: ansi-regex: 6.2.2 + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + tinybench@2.9.0: {} tinyexec@1.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 513bddc..611df31 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,10 +4,12 @@ packages: catalog: "@types/node": ^24 + "@types/tar-stream": ^3.1.4 "@types/yauzl": ^3.4.0 ajv: ^8.20.0 boxen: ^8.0.1 chalk: ^5.6.2 + tar-stream: ^3.2.0 smol-toml: ^1.4.2 tsx: ^4.23.0 typescript: ^5 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..a1ffcc7 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,251 @@ +{ + "version": 1, + "skills": { + "ask-matt": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/ask-matt/SKILL.md", + "computedHash": "0f843160e34a24f5bd12cdc7de7d40951e77fbdc05ce8f891b37ca32eac2c44d" + }, + "batch-grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/batch-grill-me/SKILL.md", + "computedHash": "f448831b8f04518b1527408f1d5024384ad5f543bdbaac6fabb7aa053ad60489" + }, + "claude-handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/claude-handoff/SKILL.md", + "computedHash": "40a7f4ec80b9826ad7d0c47b0778c99686d6d9d662112b3e175a816f3aca4c39" + }, + "code-review": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/code-review/SKILL.md", + "computedHash": "31d149a480eaa68c11e32f5ee77f0fd0b98a906834d531d881d502352edd0b8e" + }, + "codebase-design": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/codebase-design/SKILL.md", + "computedHash": "6d9d51d8caa01633fd00cf87089c5618c982321f942ac69f62565dc001c6b22f" + }, + "design-an-interface": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/design-an-interface/SKILL.md", + "computedHash": "aef348a9af675b771635623b5aa7790da4d40adc1ee88b58b6fd116eda2047d8" + }, + "diagnosing-bugs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnosing-bugs/SKILL.md", + "computedHash": "fd6c99466b7ba43be624e6e66ed6d7af2796ded7218f24c83820823977819e22" + }, + "domain-modeling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/domain-modeling/SKILL.md", + "computedHash": "363cb0f53b0b431e7c00086ad1f823500b7e1b70b5616ee969c979f0934e9e6e" + }, + "edit-article": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/personal/edit-article/SKILL.md", + "computedHash": "cb2a13b2acb6c8a0706eb3a973a2681f30fd74c00784c74ceaa98e12eb2b2fc3" + }, + "git-guardrails-claude-code": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md", + "computedHash": "8c29552c621da0121427306afd92f8e02f47f446cdb0b55ceae01de6879fcb3a" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "f361db4e15e6bfd562a9282b1dccda513910a50061f9e838ce017be9c69dde3f" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78" + }, + "grilling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grilling/SKILL.md", + "computedHash": "368d3dd7251247c69f3656d93dc83c8f1577792eacac2111f1e7981db2ece49b" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "ad03e8d4ea3cbbff66420eb7ba3cc375b5cbe1821a2449b53e863256cf5b5cde" + }, + "implement": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/implement/SKILL.md", + "computedHash": "2139cfedf24791adbc839aaab6019cff158af1e28bfead020ec6e0ce01b3e74d" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "66e8a50c83c3c724fcfe0769701b665c56cec220cc4f49cc1aee8bdfc07de94a" + }, + "loop-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/loop-me/SKILL.md", + "computedHash": "e1fcab9b531b338eb540c9d57ee089e2660e4bba1d7623ec566c6f32f77f2d26" + }, + "migrate-to-shoehorn": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", + "computedHash": "6397731ced114f3657aa88b55ed13d1344a56d77ca449c568e3200c21740fa99" + }, + "obsidian-vault": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/personal/obsidian-vault/SKILL.md", + "computedHash": "5c08eda96fd76a71793c0a8cb91bef41d518586654a14af4f2edf8f8fd0f96a7" + }, + "prototype": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/prototype/SKILL.md", + "computedHash": "faba901c53a6ca245174c4ba5db3929d14253232b3050fdbd46539720adf1ab8" + }, + "qa": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/qa/SKILL.md", + "computedHash": "223de2b02aa9ab36cfa4fe93e80e527b56e912e7ae725f9d596f6d2457afab77" + }, + "request-refactor-plan": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/request-refactor-plan/SKILL.md", + "computedHash": "08c06e587d0f979b8731f6a00c67fb8d00e563614daf80bf648d41148e553473" + }, + "research": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/research/SKILL.md", + "computedHash": "bd3e2c6826671d82c86ed0da3dac3370ebcf63b0fe847f91bb444e1fb7dac21b" + }, + "resolving-merge-conflicts": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md", + "computedHash": "28aad6f8b1b7025abc8892fa8890f68fe1533499b28a565f416b159131d22fad" + }, + "scaffold-exercises": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/scaffold-exercises/SKILL.md", + "computedHash": "354c91f6dbc9b058632f30594aacb4edc6d25012596585abb00402af8d7ec5e9" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "74e894a3509e2676d4cdb771c8eace087092430635e845e02a9cc2f757c552a4" + }, + "setup-pre-commit": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/setup-pre-commit/SKILL.md", + "computedHash": "1b140af948d0a6330c4cf52d46ca2e06a0e96fdc34d22ec8888d2b9751b99b62" + }, + "setup-ts-deep-modules": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/setup-ts-deep-modules/SKILL.md", + "computedHash": "bd5cbaa455454c2d6c27a1b734e0d820606da6c482bf3c79ec55fbc3f3e03ef3" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "81eca2a5b53a63f481c0849be7a663a8cd43d5cf53f32b644ec0a2f50cf91aa2" + }, + "teach": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/teach/SKILL.md", + "computedHash": "68999bb1a241384b2f921f3321d779b7d05eb6089077b70d88cee2aee3d75ccc" + }, + "to-questionnaire": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/to-questionnaire/SKILL.md", + "computedHash": "d8938509f3400d9977343e11e876fc6e1982b0b9c133a2f2e9d3d730a67a103e" + }, + "to-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-spec/SKILL.md", + "computedHash": "0f544cd0c099c06f0dd0b7b9ee98b4237218e7e95fd3d3c02e791efbaf74bacb" + }, + "to-tickets": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-tickets/SKILL.md", + "computedHash": "5d79577541b5cf6dade61c69844432ea20ce7b12527cdda1dd4d6fe8d59a135d" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "7c923b6a677cfe689500721f08307e2a4c46797ff169dc55ef6c34a431a0d533" + }, + "ubiquitous-language": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/deprecated/ubiquitous-language/SKILL.md", + "computedHash": "0395170031b57ea63f2eb22561542894c03e3047430a2f1118a06b411054429b" + }, + "wayfinder": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wayfinder/SKILL.md", + "computedHash": "c9e18cefd77b6b5b0ee35f59ce2fd96359aa2d2f02e3479e12c873c5402d9d43" + }, + "wizard": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/wizard/SKILL.md", + "computedHash": "dab67fb9fdcc70f3a06cf2facecf58f405f478504812103b53d4c5a84765254d" + }, + "writing-beats": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-beats/SKILL.md", + "computedHash": "7581e49a991e45e4e486b128c6391b82bd112c143cea3cc4426c23530dd545af" + }, + "writing-fragments": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-fragments/SKILL.md", + "computedHash": "972ab831c7a39dab971439fa4718b668114d3063d347d8ba29b2a19765b3f911" + }, + "writing-great-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/writing-great-skills/SKILL.md", + "computedHash": "4deb21855fb4deeeb1b8217b041faff003fd0c915d24a22f636851c520bc9c5c" + }, + "writing-shape": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-shape/SKILL.md", + "computedHash": "90899c7b3853a80da0d9ef634844b22871cc3ea6aa1bc9e4299f40484ac05392" + } + } +} diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index bcedbea..5f8043b 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,14 +1,15 @@ --- name: bailian-cli metadata: - version: "1.10.1" + version: "1.13.1" requires: bins: ["bl"] companions: ["bailian-protocol"] description: >- 阿里云百炼 / Aliyun Bailian / DashScope 资源管理与 `bl` CLI hub: 应用调用(bl app)、应用记忆、知识库检索、模型目录/模型列表、用量/额度/配额、免费额度、 - 工作空间、MCP 市场、pipeline、文件上传、console API、登录鉴权与配置。 + 工作空间、MCP 市场、pipeline、文件上传、console API、登录鉴权与配置、 + Agent skill 安装/列表/更新/卸载(bl skill add|list|update|remove,百炼 skill registry)。 用户点名百炼 / DashScope / `bl`,或继续既有 `bl` 工作流时直接使用。 共享协议(consent / 版本预检 / 鉴权 / 错误上报)在 bailian-protocol;安装本 skill 时必须同时安装 companion bailian-protocol。 家族路由:生图/生视频/配音/语音合成/转写 → bailian-gen;精调/微调/训练/数据集 → bailian-finetune; @@ -55,26 +56,27 @@ Do not guess flags — use the reference files or `--help`. Use this table only after the decision table in [`bailian-protocol`](../bailian-protocol/SKILL.md#provider-selection-and-consent) has routed the request to `bl` (class 4, or class 2 after the user picks Bailian). Hub-owned intents only — for media / fine-tune / agents.yaml, soft hand-off to the domain skill. -| User intent | Command | Notes | -| ----------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------- | -| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.7-max` | -| Search inside a Bailian-scoped workflow | `bl search web` | DashScope MCP search; not for generic web research | -| Bailian agent / workflow | `bl app call` | Needs `--app-id` | -| Find app by name | `bl app list` then `bl app call` | Console auth | -| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | -| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | -| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | -| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | -| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` for detail, `--enrich` for input params | -| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | -| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | -| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | -| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | -| Console API (advanced) | `bl console call` | Console auth | -| Bailian workspace listing | `bl workspace list` | Console auth | -| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | -| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | -| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` need `--yes` after `plan` | +| User intent | Command | Notes | +| ------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- | +| Explicit Bailian model chat / text execution | `bl text chat` | Default `qwen3.8-max` | +| Search inside a Bailian-scoped workflow | `bl search web` | DashScope MCP search; not for generic web research | +| Bailian agent / workflow | `bl app call` | Needs `--app-id` | +| Find app by name | `bl app list` then `bl app call` | Console auth | +| Bailian app memory CRUD (not host-agent memory) | `bl memory *` | [`reference/memory.md`](reference/memory.md) | +| Bailian knowledge base RAG | `bl knowledge search` / `chat` | API key + agent/workspace IDs | +| Upload a file as a step of a Bailian workflow | `bl file upload` | When you need `oss://` URL explicitly; not for generic hosting | +| Bailian model selection / recommendation | `bl advisor recommend` | Intent → candidate recall → LLM ranking | +| Bailian model catalog / pricing / params | `bl model list` | Console auth; `--model ` for detail, `--enrich` for input params | +| Install / list / update / remove registry skills | `bl skill add` / `list` / `update` / `remove` | Bailian skill registry; see [`reference/skill.md`](reference/skill.md) | +| Bailian MCP marketplace discovery / call | `bl mcp list` / `tools` / `call` | — | +| Bailian pipeline workflow (a step in a bl flow) | `bl pipeline run` / `validate` | JSON/YAML workflow definitions | +| Bailian rate limits / quota | `bl quota list` / `check` / `request` | Console auth; class 2 — ask which product first if unnamed | +| Bailian free tier / usage stats | `bl usage free` / `stats` / `freetier` | Console auth; class 2 — ask which product first if unnamed | +| Console API (advanced) | `bl console call` | Console auth | +| Bailian workspace listing | `bl workspace list` | Console auth | +| Image / video / speech / omni / vision | → skill `bailian-gen` | Fallback: `bl image\|video\|speech\|omni\|vision --help` | +| Dataset / fine-tune / deploy | → skill `bailian-finetune` | Fallback: `bl dataset\|finetune\|deploy --help` | +| agents.yaml IaC / managed-agent sessions | → skill `bailian-managed-agent` | Fallback: `bl managed-agent --help`; `apply`/`destroy` need `--yes` after `plan` | Flags, usage, and examples: see hub [`reference/`](reference/index.md) or `bl --help` — do not guess flags. Domain command details live in the owning skill's `reference/`. diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index 32f6eda..50403b9 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -20,20 +20,24 @@ Index: [index.md](index.md) ### `bl config agent` -| Field | Value | -| --------------- | --------------------------------------------------------------------------------- | -| **Name** | `config agent` | -| **Description** | Configure a coding agent to use DashScope API | -| **Usage** | `bl config agent --agent --base-url --api-key --model ` | +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `config agent` | +| **Description** | Configure a coding agent to use DashScope API | +| **Usage** | `bl config agent --agent (--base-url \| --region ) (--api-key \| --key ) --model ` | #### Flags -| Flag | Type | Required | Description | -| --------------------------------------------------------------------- | ------ | -------- | ----------------------------------------------------------------------- | -| `--agent ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | -| `--base-url ` | string | yes | API base URL | -| `--api-key ` | string | yes | API key | -| `--model ` | string | yes | Default model name | +| Flag | Type | Required | Description | +| --------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------- | +| `--agent ` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex | +| `--base-url ` | string | no | API base URL | +| `--region ` | string | no | Model Studio region (e.g. cn-beijing, ap-southeast-1); converted into --base-url. Token Plan only | +| `--api-key ` | string | no | API key | +| `--key ` | string | no | Obfuscated API key from the web console (starts with "o1\_"); decoded into --api-key | +| `--model ` | string | yes | Default model name | +| `--context-window ` | number | no | OpenClaw only: model context window in tokens (default: 256000) | +| `--wire-api ` | string | no | Codex only: wire protocol (default: responses). "chat" only works with legacy Codex <= 0.80.0 | #### Examples diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index fb1b227..d0e9f1c 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -51,6 +51,10 @@ Use this index for the skill-scoped quick index and global flags. | `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | | `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | | `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | +| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) | +| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) | +| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) | +| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) | | `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | | `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) | | `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) | @@ -82,6 +86,7 @@ Use this index for the skill-scoped quick index and global flags. | `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) | | `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) | | `search` | `web` | [search.md](search.md) | +| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) | | `text` | `chat` | [text.md](text.md) | | `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) | | `update` | `(root)` | [update.md](update.md) | diff --git a/skills/bailian-cli/reference/skill.md b/skills/bailian-cli/reference/skill.md new file mode 100644 index 0000000..7020f0f --- /dev/null +++ b/skills/bailian-cli/reference/skill.md @@ -0,0 +1,115 @@ +# `bl skill` commands + +> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Description | +| ----------------- | ---------------------------------------------------------------- | +| `bl skill add` | Install skills from the Bailian skill registry into local agents | +| `bl skill list` | List registry skills and diff against local installs | +| `bl skill remove` | Remove locally installed skills (registry is untouched) | +| `bl skill update` | Update installed skills to the latest registry versions | + +## Command details + +### `bl skill add` + +| Field | Value | +| --------------- | ---------------------------------------------------------------- | +| **Name** | `skill add` | +| **Description** | Install skills from the Bailian skill registry into local agents | +| **Usage** | `bl skill add --name ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ----------------------------------------------------- | +| `--name ` | string | yes | Skills to install: all or comma-separated skill names | + +#### Examples + +```bash +bl skill add --name all +``` + +```bash +bl skill add --name spark-video,bailian-model-recommend +``` + +### `bl skill list` + +| Field | Value | +| --------------- | ---------------------------------------------------- | +| **Name** | `skill list` | +| **Description** | List registry skills and diff against local installs | +| **Usage** | `bl skill list` | + +#### Flags + +_No command-specific flags._ + +#### Notes + +- STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed) + +#### Examples + +```bash +bl skill list +``` + +```bash +bl skill list --output json +``` + +### `bl skill remove` + +| Field | Value | +| --------------- | ------------------------------------------------------- | +| **Name** | `skill remove` | +| **Description** | Remove locally installed skills (registry is untouched) | +| **Usage** | `bl skill remove --name ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ---------------------------------------------------- | +| `--name ` | string | yes | Skills to remove: all or comma-separated skill names | + +#### Examples + +```bash +bl skill remove --name spark-video +``` + +```bash +bl skill remove --name all +``` + +### `bl skill update` + +| Field | Value | +| --------------- | ------------------------------------------------------- | +| **Name** | `skill update` | +| **Description** | Update installed skills to the latest registry versions | +| **Usage** | `bl skill update [--name ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | +| `--name ` | string | no | Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills) | + +#### Examples + +```bash +bl skill update +``` + +```bash +bl skill update --name spark-video +``` diff --git a/skills/bailian-cli/reference/text.md b/skills/bailian-cli/reference/text.md index 7e7f556..26346db 100644 --- a/skills/bailian-cli/reference/text.md +++ b/skills/bailian-cli/reference/text.md @@ -25,7 +25,7 @@ Index: [index.md](index.md) | Flag | Type | Required | Description | | ------------------------ | ------ | -------- | --------------------------------------------------------------------------- | -| `--model ` | string | no | Model ID (default: qwen3.7-max) | +| `--model ` | string | no | Model ID (default: qwen3.8-max) | | `--message ` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file | | `--messages-file ` | string | no | JSON file with messages array (use - for stdin) | | `--system ` | string | no | System prompt | diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index 06b2338..67e4b8c 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "1.10.1" + version: "1.13.1" requires: bins: ["bl"] companions: ["bailian-protocol"] diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index f62b05b..2f4a32c 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "1.10.1" + version: "1.13.1" requires: bins: ["bl"] companions: ["bailian-protocol"] diff --git a/skills/bailian-gen/reference/image.md b/skills/bailian-gen/reference/image.md index 84419f8..9b9a1f2 100644 --- a/skills/bailian-gen/reference/image.md +++ b/skills/bailian-gen/reference/image.md @@ -24,24 +24,25 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------- | -------- | ----------------------------------------------------------------------- | -| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | -| `--prompt ` | string | yes | Edit instruction text | -| `--model ` | string | no | Model ID (default: qwen-image-2.0) | -| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | -| `--n ` | number | no | Number of images (default: 1, max: 6) | -| `--seed ` | number | no | Random seed for reproducible results | -| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | -| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--out-dir ` | string | no | Download images to directory | -| `--out-prefix ` | string | no | Filename prefix (default: edited) | -| `--async` | switch | no | Return async task id without waiting | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--poll-interval ` | number | no | Polling interval when waiting (default: 3) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------- | +| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | +| `--prompt ` | string | yes | Edit instruction text | +| `--model ` | string | no | Model ID (default: qwen-image-2.0) | +| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | +| `--n ` | number | no | Number of images (default: 1, max: 6) | +| `--seed ` | number | no | Random seed for reproducible results | +| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | +| `--function ` | string | no | wanx\*-imageedit function (default: description_edit). Examples: stylization_all, description_edit | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--out-dir ` | string | no | Download images to directory | +| `--out-prefix ` | string | no | Filename prefix (default: edited) | +| `--async` | switch | no | Return async task id without waiting | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--poll-interval ` | number | no | Polling interval when waiting (default: 3) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -65,6 +66,14 @@ bl image edit --image https://example.com/photo.png --prompt "Remove the person" bl image edit --image ./photo.png --prompt "Change the style" --model wan2.7-image ``` +```bash +bl image edit --image ./photo.png --prompt "Place the subject on a table" --model wan2.5-i2i-preview +``` + +```bash +bl image edit --image ./photo.png --prompt "转换成绘本风格" --model wanx2.1-imageedit --function stylization_all +``` + ```bash bl image edit --image ./photo.png --prompt "Replace the background with a beach" --watermark false ``` @@ -127,6 +136,14 @@ bl image generate --prompt "An alien in the space" --watermark false bl image generate --prompt "sunset" --model wan2.6-t2i --async --quiet ``` +```bash +bl image generate --prompt "plush doll" --model z-image-turbo --size 1024*1024 +``` + +```bash +bl image generate --prompt "sunset" --model wanx2.0-t2i-turbo --size 1024*1024 +``` + ```bash bl image generate --prompt "Pro quality" --model qwen-image-2.0-pro ``` diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index 4fccb3e..bb8644c 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "1.10.1" + version: "1.13.1" requires: bins: ["bl"] companions: ["bailian-protocol"] diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index e80db38..548d029 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "1.10.1" + version: "1.13.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/assets/setup.md b/skills/bailian-protocol/assets/setup.md index e819b6c..71251d4 100644 --- a/skills/bailian-protocol/assets/setup.md +++ b/skills/bailian-protocol/assets/setup.md @@ -80,7 +80,7 @@ Activation selects the entire Config for every credential domain, not only model The built-in `token-plan` profile defaults to: - Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` -- Text model: `qwen3.8-max-preview` +- Text model: `qwen3.8-max` - Image model: `wan2.7-image` - Text-to-video model (`default_video_model`): `happyhorse-1.1-t2v` - Image-to-video model (`default_image_to_video_model`): `happyhorse-1.1-i2v` @@ -149,7 +149,7 @@ bl config show bl config list bl config use --name bl config use --name default -bl config set --key default-text-model --value qwen3.7-max +bl config set --key default-text-model --value qwen3.8-max bl config set --key output_dir --value ~/bailian-output ``` diff --git a/vite.config.ts b/vite.config.ts index cb7b7ac..f486f06 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,7 +36,11 @@ export default defineConfig({ hookTimeout: 60_000, }, staged: { - "*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,yaml,yml,md}": "vp check --fix", + // 用函数形式返回命令:不把匹配到的文件名插值进 argv,改为跑一次全量检查。 + // 逐文件传参会让 `vp check` 的 node 进程 argv 携带仓库内的文件名, + // 命中终端安全软件按 argv 子串匹配的进程管控规则时整个进程被 SIGKILL, + // 导致 pre-commit 无法完成。全量检查覆盖面更广,也不依赖文件名。 + "*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,yaml,yml,md}": () => "vp check --fix", }, lint: { options: { typeAware: true, typeCheck: true },