Compare commits

..

30 Commits

Author SHA1 Message Date
Gong Shiqi 2220f932b5 Merge pull request #198 from modelstudioai/codex/release-1.23.0
chore(release): prepare 1.23.0
2026-09-10 17:37:40 +08:00
若麒 5c390fad74 chore(release): prepare 1.23.0 2026-09-10 16:57:58 +08:00
Gong Shiqi 6218637f7c Merge pull request #194 from modelstudioai/feat/global-watermark-config
Feat/global watermark config
2026-09-10 16:49:33 +08:00
若麒 0c7ba721a7 Merge remote-tracking branch 'origin/main' into feat/global-watermark-config 2026-09-10 15:39:12 +08:00
Gong Shiqi 0dbf071368 Merge pull request #197 from modelstudioai/feat/speech-vocabulary
Add ASR instant hotwords, context, and vocabulary CRUD
2026-09-10 15:11:35 +08:00
clh02467605 16e30686f0 fix(speech): validate --words JSON before auth
Invalid vocabulary JSON was parsed in run(), so CI without an API key failed with AUTH(3) instead of USAGE(2).
2026-09-10 14:28:16 +08:00
clh02467605 7803d91e3f chore: fixed Formatting issues 2026-09-10 14:07:37 +08:00
clh02467605 c2d17707c0 feat(speech): add instant hotwords, context, and vocabulary CRUD 2026-09-10 14:02:05 +08:00
rendianmeng 1dfb4400cc Merge branch 'main' of github.com:modelstudioai/cli into feat/global-watermark-config 2026-09-10 10:33:23 +08:00
rendianmeng fd2077b373 feat: pipeline watermark 处理完善 2026-09-09 17:40:49 +08:00
chenanran555 2090293f85 Merge pull request #192 from modelstudioai/feat/managed-agent-ci
fix(managed-agent): improve project initialization and build validation
2026-09-08 19:39:03 +08:00
chenanran555 a245359792 chore(release): prepare 1.22.0 2026-09-08 19:26:17 +08:00
chenanran555 19bd5a5ea8 test(managed-agent): remove obsolete build confirmation flag 2026-09-08 19:01:47 +08:00
chenanran555 e262f2e574 chore(deps): bump OpenAgentPack SDK to 0.7.1 2026-09-08 18:01:41 +08:00
chenanran555 180aac9f28 fix(managed-agent): show absolute YAML paths during init 2026-09-08 16:57:15 +08:00
若麒 3477a08dad feat(config): add profile-level watermark setting 2026-09-08 16:33:40 +08:00
chenanran555 aa1484264e fix(managed-agent): improve project initialization and build validation 2026-09-08 14:46:56 +08:00
chenanran555 2767491ce6 Merge pull request #182 from modelstudioai/feat/managed-agent-ci
Feat/managed agent ci 添加managed agents模块的ci能力
2026-09-07 21:42:35 +08:00
chenanran555 efb832815a chore(release): prepare 1.21.0 2026-09-07 21:11:51 +08:00
chenanran555 902516bbad fix(managed-agent): check latest Playground version before launch
Resolve the requested npm version before reusing local builds or running processes, fetch mismatched versions on demand, and preserve explicit binary overrides. Add launcher regression tests and sync the SDK 0.7.0 lockfile.
2026-09-07 20:46:07 +08:00
chenanran555 f44fb2e95f refactor(managed-agent): use SDK project service subpaths
Replace standalone project packages with @openagentpack/sdk 0.7.0 and update project/version imports.

The pnpm lockfile remains pending until SDK 0.7.0 is published to npm; this integration has been validated against the local SDK build.
2026-09-07 19:58:47 +08:00
chenanran555 c6b9931d6f feat(managed-agent): integrate directory resource scaffolds and safe builds 2026-09-04 17:20:50 +08:00
chenanran555 a92c58420b fix(managed-agent): use CLI credentials for directory projects
Make project init fully offline, stop generating project-local credential files, bind Publish to the reviewed plan fingerprint, remove the unsafe Apply refresh-only mode, and document Agent-owned File mounts.
2026-09-04 11:22:11 +08:00
chenanran555 bcf68a31f3 Merge remote-tracking branch 'origin/main' into feat/managed-agent-ci
# Conflicts:
#	packages/commands/package.json
#	packages/commands/src/commands/managed-agent/apply.ts
#	packages/commands/src/commands/managed-agent/init.ts
#	packages/commands/tests/e2e/managed-agent.e2e.test.ts
#	skills/bailian-managed-agent/SKILL.md
#	skills/bailian-managed-agent/reference/index.md
#	skills/bailian-managed-agent/reference/managed-agent.md
2026-09-03 11:38:44 +08:00
chenanran555 8e5dd127a9 Merge pull request #177 from modelstudioai/feat/cma-command
Feat/cma command 补齐managed agent cli能力
2026-09-03 11:00:47 +08:00
Gong Shiqi 888031562f Merge pull request #189 from modelstudioai/docs/console-gateway-backend-attribution
docs(telemetry): update Console Gateway backend attribution
2026-09-02 15:18:51 +08:00
若麒 d017954ae8 docs(telemetry): update Console Gateway backend attribution 2026-09-02 15:11:24 +08:00
chenanran555 e53daf05eb feat(managed-agent): add directory project workflow 2026-08-31 15:55:10 +08:00
chenanran555 9469556671 refactor(managed-agent)!: replace Git versioning with local snapshots
Replace @openagentpack/local-git with @openagentpack/project-versions so
the CLI and Workbench share a Git-independent, lock-protected YAML
snapshot store.

- create versions only after a successful Apply
- migrate version commands to local version IDs
- remove Git project scaffolding and CI-specific Apply policy
- update tests and managed-agent skill references

BREAKING CHANGE: remove `managed-agent init --git` and
`managed-agent apply --ci`; version preview and restore now use
`--version-id` instead of `--commit`.
2026-08-26 18:00:05 +08:00
chenanran555 b93e0d586d feat(managed-agent): add Workbench and Git-backed CI versioning
Add Workbench launch and local Git version commands, enable automatic
agents.yaml versioning after successful applies, introduce CI-safe apply
policies, and reuse the shared @openagentpack/local-git package.
2026-08-25 20:41:47 +08:00
91 changed files with 5145 additions and 164 deletions
+38
View File
@@ -6,6 +6,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.23.0] - 2026-09-10
### Added
- **Profile-level watermark control** — configure `watermark` with `bl config set --key watermark --value true|false` to control the default watermark behavior for image generation and editing, video generation and editing, and reference-to-video commands.
- **ASR accuracy controls** — `bl speech recognize` now supports instant hot words with `--vocabulary`, contextual word enhancement with `--context`, and reusable pre-built vocabularies with `--vocabulary-id` for supported ASR models.
- **Speech vocabulary management** — added `bl speech vocabulary create|list|get|update|delete` to manage reusable pre-built hot-word vocabularies.
## [1.22.0] - 2026-09-08
### Changed
- **Project initialization** — `managed-agent project init` now creates `./managed-agent` by default. Use `--project .` to initialize in place. **(BREAKING)**
- **Build confirmation** — `managed-agent project build` no longer requires confirmation and rejects `--yes`. Use `--dry-run` for a read-only preview; Publish still requires confirmation. **(BREAKING)**
- **Managed Agent SDK** — upgrade to `0.7.1`. Build automatically associates active Agent-local resources while preserving explicit bindings, Skill versions, and File mount paths. Ambiguous Environment or Vault selections are rejected before writing.
### Fixed
- **Project diagnostics** — provide actionable project-root guidance and surface the underlying Build validation error.
- **YAML initialization paths** — show the absolute YAML path in creation messages and existing-file errors.
### Internal
- Expand project initialization and Build regression coverage, and remove the obsolete Build confirmation flag from the local lifecycle E2E test.
## [1.21.0] - 2026-09-07
### Added
- **Managed Agent directory projects** — initialize, validate, build, and publish local projects with Agent-scoped resource configuration and resource examples.
- **Local project versions** — enable or disable snapshot versioning, list and preview history, and restore project files without Git.
- **Project Workbench** — edit resources, review changes, publish updates, and manage local versions in a browser.
### Changed
- **Playground version resolution** — check npm before launch, reuse matching local versions, and fetch the latest version when needed; retain explicit version and binary overrides.
- **Managed Agent SDK** — upgrade to `0.7.0` and consume project workspace and version services through SDK subpath exports.
## [1.20.0] - 2026-09-03
> Managed Agents now combines YAML-first infrastructure management with direct Bailian AgentStudio resource and runtime operations.
+38
View File
@@ -6,6 +6,44 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.23.0] - 2026-09-10
### 新增
- **Profile 级水印控制** —— 可通过 `bl config set --key watermark --value true|false` 设置图片生成与编辑、视频生成与编辑以及参考生视频命令的默认水印行为。
- **ASR 准确率增强** —— `bl speech recognize` 现支持通过 `--vocabulary` 传入即时热词、通过 `--context` 增强上下文词表,以及通过 `--vocabulary-id` 使用适用于对应 ASR 模型的预编译热词表。
- **语音热词表管理** —— 新增 `bl speech vocabulary create|list|get|update|delete`,用于管理可复用的预编译热词表。
## [1.22.0] - 2026-09-08
### 变更
- **项目初始化** —— `managed-agent project init` 默认创建 `./managed-agent` 子目录;如需原地初始化,请使用 `--project .`。**(BREAKING)**
- **Build 确认机制** —— `managed-agent project build` 无需确认,并且不再接受 `--yes`。使用 `--dry-run` 可只读预览;Publish 仍需显式确认。**(BREAKING)**
- **Managed Agent SDK** —— 升级至 `0.7.1`。Build 自动关联 Agent 目录下已启用的资源,保留显式引用、Skill 版本和 File 挂载路径;Environment 或 Vault 选择存在歧义时,在写入前报错。
### 修复
- **项目诊断** —— 提供可操作的项目根目录提示,并展示 Build 校验失败的具体原因。
- **YAML 初始化路径** —— 创建成功及文件已存在的错误信息均展示 YAML 绝对路径。
### 内部
- 补充项目初始化和 Build 回归覆盖,移除本地闭环 E2E 测试中过时的 Build 确认参数。
## [1.21.0] - 2026-09-07
### 新增
- **Managed Agent 目录项目** —— 支持初始化、校验、构建和发布本地项目,按 Agent 组织资源配置,并提供资源示例。
- **本地项目版本管理** —— 无需 Git 即可启停快照版本管理、查看和预览历史,以及恢复项目文件。
- **项目 Workbench** —— 在浏览器中编辑资源、审阅变更、发布更新和管理本地版本。
### 变更
- **Playground 版本选择** —— 启动前查询 npm,复用版本一致的本地安装,否则按需下载最新版;保留显式版本和启动文件覆盖。
- **Managed Agent SDK** —— 升级至 `0.7.0`,通过 SDK 子路径复用项目目录和版本服务。
## [1.20.0] - 2026-09-03
> Managed Agent 现在同时提供 YAML-first 基础设施管理与百炼 AgentStudio 资源、运行时 API 操作。
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+1
View File
@@ -19,6 +19,7 @@
- 类型由 `ParsedFlags<typeof FLAGS>` 推导;避免手写 `flags.x as number` 这类断言
- 单 flag 必填用 `required: true`;跨 flag / 值相关校验放 `validate`
- 默认值 fallback 写在命令实现或 `Settings` 解析层,不要重复解析 env/config
- 需要在高风险确认前检查本地路径时,可用异步 `validate`;runtime 会在鉴权和确认前等待它完成。这里只允许本地只读检查,不写文件、不请求远端。非缺参的环境错误应抛出 `BailianError`,避免裸命令调用被当成缺参而仅显示 help。
### B. 鉴权 / 全局选项
+2 -1
View File
@@ -30,7 +30,7 @@
| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) |
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ |
| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` |
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 无 | 有:`pid=bailian-cli-node`、`authMethod=console` |
| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 有:Console Gateway 后端直接标识 | 有:`pid=bailian-cli-node`、`authMethod=console` |
| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=openapi` |
| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` |
@@ -41,6 +41,7 @@
- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入
- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent`
- Console Gateway 用户数据由后端直接区分,不依赖 `x-dashscope-source-config`;AEM 的 `authMethod=console` 是独立的命令侧统计
- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product`、`action`、`api` 是鉴权或路由字段,不计为埋点标识
### 2. 后端渠道参数
+1
View File
@@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble
| ------------------------ | --------------------------------------------------------------------------------- |
| Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." |
| Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." |
| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." |
| Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." |
| Model selection | "Recommend a model for image understanding and customer support." |
| About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." |
+1
View File
@@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex
| ---------------- | ----------------------------------------------------------------------- |
| Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” |
| 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” |
| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” |
| 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” |
| 模型选型 | “推荐一个适合图片理解和智能客服的模型。” |
| 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.20.0",
"version": "1.23.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+34
View File
@@ -70,6 +70,11 @@ import {
searchWeb,
speechSynthesize,
speechRecognize,
speechVocabularyCreate,
speechVocabularyList,
speechVocabularyGet,
speechVocabularyUpdate,
speechVocabularyDelete,
fileUpload,
consoleCall,
usageFree,
@@ -140,6 +145,18 @@ import {
managedAgentPlan,
managedAgentApply,
managedAgentDestroy,
managedAgentPlayground,
managedAgentProjectInit,
managedAgentProjectValidate,
managedAgentProjectBuild,
managedAgentProjectPublish,
managedAgentProjectWorkbench,
managedAgentProjectVersionEnable,
managedAgentProjectVersionDisable,
managedAgentProjectVersionStatus,
managedAgentProjectVersionList,
managedAgentProjectVersionPreview,
managedAgentProjectVersionRestore,
managedAgentStateList,
managedAgentStateShow,
managedAgentStateRm,
@@ -274,6 +291,11 @@ export const commands: Record<string, AnyCommand> = {
"search web": searchWeb,
"speech synthesize": speechSynthesize,
"speech recognize": speechRecognize,
"speech vocabulary create": speechVocabularyCreate,
"speech vocabulary list": speechVocabularyList,
"speech vocabulary get": speechVocabularyGet,
"speech vocabulary update": speechVocabularyUpdate,
"speech vocabulary delete": speechVocabularyDelete,
"file upload": fileUpload,
"console call": consoleCall,
"usage free": usageFree,
@@ -344,6 +366,18 @@ export const commands: Record<string, AnyCommand> = {
"managed-agent plan": managedAgentPlan,
"managed-agent apply": managedAgentApply,
"managed-agent destroy": managedAgentDestroy,
"managed-agent playground": managedAgentPlayground,
"managed-agent project init": managedAgentProjectInit,
"managed-agent project validate": managedAgentProjectValidate,
"managed-agent project build": managedAgentProjectBuild,
"managed-agent project publish": managedAgentProjectPublish,
"managed-agent project workbench": managedAgentProjectWorkbench,
"managed-agent project version enable": managedAgentProjectVersionEnable,
"managed-agent project version disable": managedAgentProjectVersionDisable,
"managed-agent project version status": managedAgentProjectVersionStatus,
"managed-agent project version list": managedAgentProjectVersionList,
"managed-agent project version preview": managedAgentProjectVersionPreview,
"managed-agent project version restore": managedAgentProjectVersionRestore,
"managed-agent state list": managedAgentStateList,
"managed-agent state show": managedAgentStateShow,
"managed-agent state rm": managedAgentStateRm,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.20.0",
"version": "1.23.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
@@ -40,7 +40,7 @@
"check": "vp check"
},
"dependencies": {
"@openagentpack/sdk": "0.5.0",
"@openagentpack/sdk": "0.7.1",
"bailian-cli-core": "workspace:*",
"bailian-cli-runtime": "workspace:*",
"boxen": "catalog:",
+3 -2
View File
@@ -12,9 +12,9 @@ export default defineCommand({
valueHint: "<key>",
description: {
"en-US":
"Config key (language, base_url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"Config key (language, base_url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"zh-CN":
"配置项名称(language、base_url、output、output_dir、timeout、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)",
"配置项名称(language、base_url、output、output_dir、timeout、watermark、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)",
},
required: true,
},
@@ -29,6 +29,7 @@ export default defineCommand({
"--key language --value zh-CN",
"--key output --value json",
"--key timeout --value 600",
"--key watermark --value false",
"--key base_url --value https://dashscope.aliyuncs.com",
"--config company-plan --key api-key-capabilities --value text.chat,image.generate",
],
@@ -3,6 +3,7 @@ import {
ExitCode,
isApiKeyCapability,
normalizeModelBaseUrl,
parseBooleanValue,
SUPPORTED_LANGUAGES,
} from "bailian-cli-core";
@@ -13,6 +14,7 @@ export const VALID_KEYS = [
"output",
"output_dir",
"timeout",
"watermark",
"api_key",
"access_token",
"access_key_id",
@@ -62,7 +64,7 @@ export const UI_ENUM_KEYS: Record<string, string[]> = {
};
// Keys the UI renders as a true/false dropdown and stores as a boolean.
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry"]);
export const UI_BOOLEAN_KEYS = new Set<string>(["telemetry", "watermark"]);
// Default model each `default_*_model` key falls back to when left unset. These
// mirror the inline `|| "<model>"` fallbacks in the generation commands
@@ -164,7 +166,10 @@ export function resolveKey(key: string): string {
* Validate a single config entry and coerce its value to the stored type.
* Throws BailianError(USAGE) for unknown keys or invalid values.
*/
export function validateAndCoerce(key: string, value: string): string | number | string[] {
export function validateAndCoerce(
key: string,
value: string,
): string | number | boolean | string[] {
const resolvedKey = resolveKey(key);
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
@@ -201,6 +206,8 @@ export function validateAndCoerce(key: string, value: string): string | number |
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
if (resolvedKey === "watermark") return parseBooleanValue(value, "watermark");
if (resolvedKey === "api_key_capabilities") {
let rawCapabilities: unknown;
if (value.trim().startsWith("[")) {
@@ -18,6 +18,7 @@ export default defineCommand({
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
watermark: settings.watermark,
config: settings.configName ?? "default",
config_file: store.path,
};
+1 -1
View File
@@ -207,7 +207,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size: resolveImageSize(flags.size, route.sizeProfile),
@@ -185,7 +185,7 @@ export default defineCommand({
"prompt-extend",
);
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const parameters: NonNullable<DashScopeImageRequest["parameters"]> = {
size,
@@ -30,9 +30,11 @@ export const BAILIAN_PROVIDER = "bailian";
interface AgentConfigOptions {
resolveEnv?: boolean;
environment?: Record<string, string>;
projectName?: string;
statePath?: string;
credentials?: CredentialScope;
overrideBailianBaseUrl?: boolean;
}
/**
@@ -56,7 +58,9 @@ export async function resolveAgentProjectConfig(
prepareProviderEnv();
const resolved = await resolveProjectConfig(filePath, options);
normalizeInterpolatedProviderBlocks(resolved.config.providers);
injectProviderCredentials(resolved.config.providers, host);
injectProviderCredentials(resolved.config.providers, host, {
overrideBaseUrl: options.overrideBailianBaseUrl,
});
scrubCredentialEnv();
assertBailianOnlyProviders(resolved.config.providers);
if ((options.credentials ?? "all") !== "none") {
@@ -99,9 +99,10 @@ export function prepareProviderEnv(): void {
* Override the bailian provider block with bl's authStage-resolved credential, so
* the bailian API key is authoritatively the CLI auth chain's — never a config
* file bare-read or a stale env value. `api_key` is replaced unconditionally
* when a credential resolved; `base_url` / `workspace_id` are filled only when
* the block references them and the interpolated value is empty (a literal in
* agents.yaml is respected).
* when a credential resolved. `base_url` is normally filled only when empty so
* a literal in agents.yaml remains supported; directory projects pass
* `overrideBaseUrl` so their connection always follows the Bailian CLI auth
* chain. `workspace_id` is filled only when empty.
*
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
* paths onto it verbatim; a value already ending in the suffix is left as-is.
@@ -114,6 +115,7 @@ export function prepareProviderEnv(): void {
export function injectProviderCredentials(
providers: Record<string, unknown>,
host: CredentialHost,
options: { overrideBaseUrl?: boolean } = {},
): void {
const bailian = providers.bailian;
if (!bailian || typeof bailian !== "object") return;
@@ -121,7 +123,7 @@ export function injectProviderCredentials(
const cred = host.client.exportApiCredential();
if (cred) block.api_key = cred.token;
if ("base_url" in block && !block.base_url) {
if ("base_url" in block && (options.overrideBaseUrl || !block.base_url)) {
// Defensive normalization: the auth chain already normalizes base_url to
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
const origin = host.client.baseUrl.replace(/\/+$/, "");
@@ -0,0 +1,432 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { promisify } from "node:util";
import { BailianError, type Client, ExitCode, type Settings } from "bailian-cli-core";
import { emitBare } from "bailian-cli-runtime";
const PLAYGROUND_PACKAGE = "@openagentpack/playground";
const execFileAsync = promisify(execFile);
const DEFAULT_PORT = 4848;
const PLAYGROUND_URL_PATTERN = /running at http:\/\/localhost:(\d+)/i;
export interface PlaygroundLaunchOptions {
port?: number;
open: boolean;
file?: string;
project?: string;
agent?: string;
surface: "preview" | "workbench";
client: Client;
settings: Settings;
}
interface Launcher {
command: string;
args: string[];
version?: string;
fetched: boolean;
}
interface ExistingPlayground {
version: string;
pid: number;
projectId?: string;
}
interface PlaygroundProjectSummary {
status?: string;
agents?: Array<{ agent?: { id?: string } }>;
}
export interface PlaygroundBrowserTarget {
url: string;
warning?: string;
}
export async function launchManagedAgentPlayground(
options: PlaygroundLaunchOptions,
): Promise<void> {
assertSupportedNodeVersion();
const port = options.port ?? DEFAULT_PORT;
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
throw new BailianError(`Invalid --port '${port}'.`, ExitCode.USAGE);
}
const sourcePath = resolve(
options.surface === "workbench" ? (options.project ?? ".") : (options.file ?? "agents.yaml"),
);
const projectId = createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
const launcher = await resolveLauncher();
const existing = await probeExistingPlayground(port);
if (existing) {
const reusable =
existing.projectId === projectId &&
(launcher.version === undefined || existing.version === launcher.version);
if (reusable) {
emitBare(`Workbench already running at http://localhost:${port} (pid ${existing.pid}).`);
await openPlaygroundSurface(port, options.surface, options.agent, options.open);
return;
}
const released = await replaceExistingPlayground(existing, port);
if (!released) {
throw new BailianError(
`Could not stop the existing Workbench process (pid ${existing.pid}) on port ${port}.`,
ExitCode.GENERAL,
"Stop it manually or choose another --port.",
);
}
}
const environment = buildPlaygroundEnvironment(options, port, sourcePath);
if (launcher.fetched) {
emitBare(`Fetching ${PLAYGROUND_PACKAGE} (first run may take a moment)...`);
}
const child = spawn(launcher.command, launcher.args, {
env: environment,
stdio: ["inherit", "pipe", "inherit"],
});
const removeSignalForwarding = forwardSignals(child);
try {
const readyPort = await waitForPlaygroundReady(child, port, 30_000, projectId);
if (readyPort === null) {
throw new BailianError(
`Workbench did not become ready in time. Check the logs above, then open http://localhost:${port}.`,
ExitCode.GENERAL,
);
}
emitBare(`Workbench ready at http://localhost:${readyPort}`);
await openPlaygroundSurface(readyPort, options.surface, options.agent, options.open);
const exitCode = await waitForChildExit(child);
if (exitCode !== 0) {
throw new BailianError(`Workbench exited with code ${exitCode}.`, ExitCode.GENERAL);
}
} finally {
removeSignalForwarding();
}
}
export function playgroundBrowserTargetFromSummary(
baseUrl: string,
summary: PlaygroundProjectSummary,
requestedAgent?: string,
): PlaygroundBrowserTarget {
if (summary.status !== "valid") return { url: baseUrl };
const agentIds = (summary.agents ?? [])
.map((entry) => entry.agent?.id?.trim())
.filter((agentId): agentId is string => Boolean(agentId));
const requested = requestedAgent?.trim();
if (requested) {
if (agentIds.includes(requested)) {
return { url: `${baseUrl}/agents/${encodeURIComponent(requested)}/preview` };
}
return {
url: baseUrl,
warning: `Agent '${requested}' was not found. Opening the project Workbench instead.`,
};
}
if (agentIds.length === 1) {
return { url: `${baseUrl}/agents/${encodeURIComponent(agentIds[0]!)}/preview` };
}
if (agentIds.length > 1) {
return {
url: baseUrl,
warning:
"This project declares multiple Agents. Opening the Workbench; rerun with --agent <id> for Preview.",
};
}
return { url: baseUrl };
}
function assertSupportedNodeVersion(): void {
const majorVersion = Number(process.versions.node.split(".")[0]);
if (Number.isFinite(majorVersion) && majorVersion >= 22) return;
throw new BailianError(
"Managed Agent Workbench requires Node.js 22 or later.",
ExitCode.USAGE,
"Upgrade Node.js for Workbench; other Bailian CLI commands continue to support Node.js 18.17+.",
);
}
export async function resolveLauncher(): Promise<Launcher> {
const explicit =
process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN?.trim() ||
process.env.AGENTS_PLAYGROUND_BIN?.trim();
if (explicit) {
if (!existsSync(explicit)) {
throw new BailianError(
`Configured Workbench binary does not exist: ${explicit}`,
ExitCode.USAGE,
);
}
return { command: process.execPath, args: [explicit], fetched: false };
}
const requestedVersion = process.env.BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION?.trim() || "latest";
const { stdout } = await execFileAsync(
"npm",
[
"view",
`${PLAYGROUND_PACKAGE}@${requestedVersion}`,
"version",
"--json",
"--prefer-online",
"--fetch-retries=0",
"--fetch-timeout=10000",
],
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
);
let resolvedVersion: unknown;
try {
resolvedVersion = JSON.parse(stdout);
} catch {
resolvedVersion = undefined;
}
if (
typeof resolvedVersion !== "string" ||
!/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(resolvedVersion)
) {
throw new BailianError(
"npm did not return a single valid Playground version. Specify an exact version or dist-tag. / npm 未返回唯一有效的 Playground 版本号。请指定精确版本或 dist-tag。",
ExitCode.GENERAL,
);
}
const installed = resolveInstalledPlayground();
if (installed?.version === resolvedVersion) return installed;
const monorepoBinary = findLocalPlaygroundBin(process.cwd());
if (
monorepoBinary &&
readPlaygroundVersion(resolve(dirname(monorepoBinary), "../../package.json")) ===
resolvedVersion
) {
return {
command: process.execPath,
args: [monorepoBinary],
version: resolvedVersion,
fetched: false,
};
}
return {
command: "npx",
args: ["-y", `${PLAYGROUND_PACKAGE}@${resolvedVersion}`],
version: resolvedVersion,
fetched: true,
};
}
function readPlaygroundVersion(manifestPath: string): string | undefined {
try {
return (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: string }).version;
} catch {
return undefined;
}
}
function resolveInstalledPlayground(): Launcher | undefined {
try {
const require = createRequire(import.meta.url);
const packageJsonPath = require.resolve(`${PLAYGROUND_PACKAGE}/package.json`);
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
version?: string;
bin?: string | Record<string, string>;
};
const relativeBinary =
typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["agents-playground"];
if (!relativeBinary) return undefined;
const binaryPath = resolve(dirname(packageJsonPath), relativeBinary);
if (!existsSync(binaryPath)) return undefined;
return {
command: process.execPath,
args: [binaryPath],
version: manifest.version,
fetched: false,
};
} catch {
return undefined;
}
}
function findLocalPlaygroundBin(startDirectory: string): string | undefined {
let directory = startDirectory;
for (let depth = 0; depth < 10; depth += 1) {
const candidate = resolve(directory, "packages/playground/dist/bin/playground.js");
if (existsSync(candidate)) return candidate;
const parent = dirname(directory);
if (parent === directory) break;
directory = parent;
}
return undefined;
}
function buildPlaygroundEnvironment(
options: PlaygroundLaunchOptions,
port: number,
sourcePath: string,
): NodeJS.ProcessEnv {
const credential = options.client.exportApiCredential();
const environment: NodeJS.ProcessEnv = {
...process.env,
PORT: String(port),
AGENTS_PLAYGROUND_TOKEN: randomBytes(32).toString("hex"),
};
if (options.surface === "workbench") {
delete environment.AGENTS_CONFIG_PATH;
environment.AGENTS_PROJECT_ROOT = sourcePath;
} else {
delete environment.AGENTS_PROJECT_ROOT;
environment.AGENTS_CONFIG_PATH = sourcePath;
}
if (credential) environment.DASHSCOPE_API_KEY = credential.token;
const baseUrl = options.client.baseUrl.replace(/\/+$/, "");
environment.BAILIAN_BASE_URL = baseUrl.endsWith("/api/v1/agentstudio")
? baseUrl
: `${baseUrl}/api/v1/agentstudio`;
if (options.settings.workspaceId) {
environment.BAILIAN_WORKSPACE_ID = options.settings.workspaceId;
}
return environment;
}
async function waitForPlaygroundReady(
child: ChildProcess,
fallbackPort: number,
timeoutMs: number,
expectedProjectId: string,
): Promise<number | null> {
let port = fallbackPort;
let outputBuffer = "";
child.stdout?.on("data", (chunk: Buffer | string) => {
process.stdout.write(chunk);
outputBuffer += chunk.toString();
const match = outputBuffer.match(PLAYGROUND_URL_PATTERN);
if (match?.[1]) port = Number(match[1]);
});
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
if (child.exitCode !== null) return null;
try {
const response = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(1_000),
});
const body = response.ok
? ((await response.json()) as { playground?: { project_id?: string } })
: undefined;
if (body?.playground?.project_id === expectedProjectId) return port;
} catch {
// Not ready yet.
}
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 300));
}
return null;
}
async function probeExistingPlayground(port: number): Promise<ExistingPlayground | null> {
try {
const response = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(2_000),
});
if (!response.ok) return null;
const body = (await response.json()) as {
playground?: { version?: string; pid?: number; project_id?: string };
};
if (!body.playground?.pid) return null;
return {
version: body.playground.version ?? "unknown",
pid: body.playground.pid,
projectId: body.playground.project_id,
};
} catch {
return null;
}
}
async function replaceExistingPlayground(
existing: ExistingPlayground,
port: number,
): Promise<boolean> {
emitBare(`Replacing Workbench v${existing.version} (pid ${existing.pid}) on port ${port}...`);
try {
process.kill(existing.pid, "SIGTERM");
} catch {
return true;
}
for (let attempt = 0; attempt < 30; attempt += 1) {
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 100));
if (!(await probeExistingPlayground(port))) return true;
}
return false;
}
async function openPlaygroundSurface(
port: number,
surface: "preview" | "workbench",
requestedAgent: string | undefined,
shouldOpen: boolean,
): Promise<void> {
if (!shouldOpen) return;
const target =
surface === "workbench"
? { url: `http://localhost:${port}` }
: await resolvePlaygroundBrowserTarget(port, requestedAgent);
if (target.warning) emitBare(`Warning: ${target.warning}`);
openBrowser(target.url);
}
async function resolvePlaygroundBrowserTarget(
port: number,
requestedAgent?: string,
): Promise<PlaygroundBrowserTarget> {
const baseUrl = `http://localhost:${port}`;
try {
const response = await fetch(`${baseUrl}/api/project`, {
signal: AbortSignal.timeout(3_000),
});
if (!response.ok) return { url: baseUrl };
return playgroundBrowserTargetFromSummary(
baseUrl,
(await response.json()) as PlaygroundProjectSummary,
requestedAgent,
);
} catch {
return { url: baseUrl };
}
}
function openBrowser(url: string): void {
const command =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
const args = process.platform === "win32" ? ["", url] : [url];
try {
spawn(command, args, {
stdio: "ignore",
detached: true,
shell: process.platform === "win32",
}).unref();
} catch {
emitBare(`Could not open a browser automatically. Visit ${url}`);
}
}
function forwardSignals(child: ChildProcess): () => void {
const forwardInterrupt = () => child.kill("SIGINT");
const forwardTerminate = () => child.kill("SIGTERM");
process.on("SIGINT", forwardInterrupt);
process.on("SIGTERM", forwardTerminate);
return () => {
process.off("SIGINT", forwardInterrupt);
process.off("SIGTERM", forwardTerminate);
};
}
function waitForChildExit(child: ChildProcess): Promise<number> {
if (child.exitCode !== null) return Promise.resolve(child.exitCode);
return new Promise((resolveExit, rejectExit) => {
child.once("exit", (exitCode) => resolveExit(exitCode ?? 0));
child.once("error", rejectExit);
});
}
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
BailianError,
defineCommand,
@@ -12,6 +13,7 @@ import { emitBare, emitResult } from "bailian-cli-runtime";
const GITIGNORE_ADDITIONS = `
# agents
agents.state.json
.openagentpack/versions/
.env
`;
@@ -71,8 +73,8 @@ const INIT_FLAGS = {
export default defineCommand({
description: {
"en-US": "Create a new agents.yaml template",
"zh-CN": "创建新的 agents.yaml 模板",
"en-US": "Create an agents.yaml template",
"zh-CN": "创建 agents.yaml 模板",
},
auth: "none",
usageArgs: "[--agent-name <name>] [--file <path>] [--force]",
@@ -86,7 +88,7 @@ export default defineCommand({
if (existsSync(file) && !flags.force) {
throw new BailianError(
`${file} already exists.`,
`${resolve(file)} already exists.`,
ExitCode.USAGE,
"Pass --force to overwrite.",
);
@@ -127,7 +129,7 @@ export default defineCommand({
if (format === "json") {
emitResult({ created: file, provider: "bailian", agent: agentName }, format);
} else {
emitBare(`Created ${file}`);
emitBare(`Created ${resolve(file)}`);
emitBare(
"Credentials: run `bl auth login --api-key <key> --base-url <url>`, or set DASHSCOPE_API_KEY / BAILIAN_BASE_URL.",
);
@@ -0,0 +1,468 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import {
BailianError,
defineCommand,
detectOutputFormat,
ExitCode,
type FlagsDef,
} from "bailian-cli-core";
import { emitBare, emitResult } from "bailian-cli-runtime";
import {
commitProjectBuild,
createDirectoryWorkspaceVersionService,
executeProjectPublish,
initializeDirectoryProject,
planProjectPublish,
previewProjectBuild,
type ProjectBuildResolver,
resolveDirectoryProjectRoot,
validateDirectoryProject,
} from "@openagentpack/sdk/project-workspace";
import { CREDENTIALS_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts";
import { withStdoutProtected } from "./_engine/console-capture.ts";
import { withAgentErrors } from "./_engine/errors.ts";
import { renderAgentFeedback } from "./_engine/feedback.ts";
import { launchManagedAgentPlayground } from "./_engine/playground-launcher.ts";
import { installSdkTransport } from "./_engine/transport.ts";
import { formatResourceLabel } from "./_engine/address-utils.ts";
const PROJECT_FLAG = {
project: {
type: "string",
valueHint: "<directory>",
description: {
"en-US": "Directory project root (default: current directory)",
"zh-CN": "目录项目根路径(默认:当前目录)",
},
},
} satisfies FlagsDef;
const JSON_FORMAT = "json" as const;
export const managedAgentProjectInit = defineCommand({
description: {
"en-US": "Create a directory project or convert the local agents.yaml",
"zh-CN": "创建目录项目,或转换当前 agents.yaml",
},
auth: "none",
usageArgs: "[--project <directory>]",
flags: {
project: {
...PROJECT_FLAG.project,
description: {
"en-US": "Directory project root (default: ./managed-agent under the current directory)",
"zh-CN": "目录项目根路径(默认:当前目录下的 ./managed-agent)",
},
},
},
exampleArgs: ["", "--project ./my-agent", "--project ."],
notes: [
{
"en-US":
"Without --project, creates a managed-agent/ subdirectory. Enter it before running other project commands. Use --project . to initialize in place or convert the current agents.yaml; existing project files are not overwritten.",
"zh-CN":
"不传 --project 时创建 managed-agent/ 子目录;后续项目操作请先进入该目录。使用 --project . 可在当前目录初始化或转换 agents.yaml;不会覆盖已有项目文件。",
},
{
"en-US":
"New projects include Skill, File, Vault, and Environment examples under each resource directory's _examples/. They are not referenced by agent.json and are excluded from Build/Publish. Copy an example outside _examples/ to enable it, then configure its Agent reference.",
"zh-CN":
"新项目在四类资源目录的 _examples/ 下生成配置示例,不写入 agent.json 引用,也不参与 Build/Publish。需要使用时,将示例复制到 _examples/ 外,再配置 Agent 引用。",
},
],
async run(ctx) {
const projectRoot = ctx.flags.project ?? "./managed-agent";
if (ctx.settings.dryRun) {
emitResult(
{
would_initialize_project: projectRoot,
},
detectOutputFormat(ctx.settings.output),
);
return;
}
const result = await initializeDirectoryProject({ projectRoot });
emitResult(result, detectOutputFormat(ctx.settings.output));
},
});
export const managedAgentProjectValidate = defineCommand({
description: { "en-US": "Validate a directory Agent project", "zh-CN": "校验目录式 Agent 项目" },
auth: "none",
usageArgs: "[--project <directory>]",
flags: PROJECT_FLAG,
exampleArgs: ["", "--project ./my-agent"],
async run(ctx) {
const result = await validateDirectoryProject(ctx.flags.project ?? ".");
const format = detectOutputFormat(ctx.settings.output);
if (format === JSON_FORMAT) emitResult(safeInspection(result), format);
else {
for (const diagnostic of [...result.diagnostics, ...result.warnings]) {
emitBare(`${diagnostic.severity}: ${diagnostic.code}: ${diagnostic.message}`);
}
if (!result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
emitBare(`Project is valid (${result.project_revision.slice(0, 12)}).`);
}
}
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
throw new BailianError("Directory project validation failed.", ExitCode.GENERAL);
}
},
});
export const managedAgentProjectBuild = defineCommand({
description: {
"en-US": "Organize directory source and generate the immutable Publish Build",
"zh-CN": "整理目录源文件并生成不可变的发布 Build",
},
auth: "none",
notes: [
{
"en-US":
"Build writes local project files without confirmation, including inferred resource associations and migration of plaintext Vault secrets into .env. Use --dry-run to preview without writing. Publish still requires explicit confirmation before remote changes.",
"zh-CN":
"Build 无需确认即可写入本地项目文件,包括推断的资源关联及将 Vault 明文密钥移入 .env。使用 --dry-run 可只预览不写入。Publish 变更远端资源前仍需显式确认。",
},
],
usageArgs: "[--project <directory>]",
flags: PROJECT_FLAG,
exampleArgs: ["", "--dry-run", "--project ./my-agent"],
async validate(flags) {
await withAgentErrors(async () => {
const root = await resolveDirectoryProjectRoot(flags.project ?? ".");
const metadata = await stat(join(root, "project.json")).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (!metadata?.isFile()) {
throw new BailianError(
`Not a project root: ${root} (project.json is missing).`,
ExitCode.USAGE,
"Run from the directory containing project.json, or pass --project <directory>.",
);
}
});
return undefined;
},
async run(ctx) {
const root = ctx.flags.project ?? ".";
const preview = await previewProjectBuild(root);
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
emitResult(safeInspection(preview), format);
return;
}
if (!preview.can_build)
throw new BailianError(
preview.diagnostics.find((diagnostic) => diagnostic.severity === "error")?.message ??
"Directory project is invalid and cannot be built.",
ExitCode.GENERAL,
);
const built = await commitProjectBuild({
projectRoot: root,
baseRevision: preview.project_revision,
});
emitResult(
{ manifest: built.manifest, organization_moves: preview.organization_moves },
format,
);
},
});
export const managedAgentProjectPublish = defineCommand({
description: {
"en-US": "Publish the current directory-project Build and record a version",
"zh-CN": "发布当前目录项目 Build 并记录版本",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This publishes the current directory-project Build and may create, update, or delete remote managed Agent resources.",
"zh-CN": "该操作会发布当前目录项目 Build,可能创建、更新或删除远端托管 Agent 资源。",
},
},
usageArgs: "[--project <directory>] [--no-refresh] [--concurrency <n>]",
flags: {
...PROJECT_FLAG,
noRefresh: {
type: "switch",
description: {
"en-US": "Skip remote refresh before planning",
"zh-CN": "规划前跳过远端刷新",
},
},
concurrency: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Maximum parallel resource operations",
"zh-CN": "最大并行资源操作数",
},
},
},
exampleArgs: ["--yes", "--project ./my-agent --yes"],
notes: CREDENTIALS_NOTE,
async run(ctx) {
installSdkTransport(ctx);
const root = ctx.flags.project ?? ".";
const resolveBuild: ProjectBuildResolver = async (buildPath, options) =>
(await resolveAgentProjectConfig(ctx, buildPath, {
environment: options?.environment,
overrideBailianBaseUrl: true,
})) as unknown as Awaited<ReturnType<ProjectBuildResolver>>;
const planned = await withAgentErrors(() =>
withStdoutProtected(() =>
planProjectPublish(root, {
provider: "bailian",
refresh: !ctx.flags.noRefresh,
quiet: true,
onFeedback: renderAgentFeedback,
resolveBuild,
}),
),
);
const actions = planned.planned.plan.actions.filter((action) => action.action !== "no-op");
if (ctx.settings.dryRun) {
emitResult(
{
project_revision: planned.project_revision,
build_manifest: planned.build_manifest,
plan: planned.planned.plan,
},
detectOutputFormat(ctx.settings.output),
);
return;
}
for (const action of actions) {
const marker = action.action === "create" ? "+" : action.action === "update" ? "~" : "-";
emitBare(`${marker} ${formatResourceLabel(action.address)}`);
}
const result = await withAgentErrors(() =>
withStdoutProtected(() =>
executeProjectPublish({
projectRoot: planned.project_root,
expectedProjectRevision: planned.project_revision,
expectedYamlHash: planned.build_manifest.yaml_hash,
expectedPlanFingerprint: planned.plan_fingerprint,
provider: "bailian",
refresh: !ctx.flags.noRefresh,
concurrency: ctx.flags.concurrency,
policy: "force",
onFeedback: renderAgentFeedback,
resolveBuild,
}),
),
);
emitResult(result, detectOutputFormat(ctx.settings.output));
},
});
export const managedAgentProjectWorkbench = defineCommand({
description: {
"en-US": "Launch the directory project Workbench",
"zh-CN": "启动目录项目 Workbench",
},
auth: "apiKey",
usageArgs: "[--project <directory>] [--port <n>] [--no-open]",
flags: {
...PROJECT_FLAG,
port: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Local port (default: 4848)", "zh-CN": "本地端口(默认:4848)" },
},
noOpen: {
type: "switch",
description: { "en-US": "Do not open a browser", "zh-CN": "不自动打开浏览器" },
},
},
exampleArgs: ["", "--project ./my-agent --no-open"],
notes: CREDENTIALS_NOTE,
async run(ctx) {
const root = ctx.flags.project ?? ".";
if (ctx.settings.dryRun) {
emitResult(
{ would_launch: "workbench", project_root: root, port: ctx.flags.port ?? 4848 },
detectOutputFormat(ctx.settings.output),
);
return;
}
await launchManagedAgentPlayground({
project: root,
port: ctx.flags.port ?? 4848,
open: !ctx.flags.noOpen,
surface: "workbench",
client: ctx.client,
settings: ctx.settings,
});
},
});
export const managedAgentProjectVersionEnable = versionToggleCommand(true);
export const managedAgentProjectVersionDisable = versionToggleCommand(false);
function versionToggleCommand(enabled: boolean) {
return defineCommand({
description: enabled
? { "en-US": "Enable directory project versions", "zh-CN": "启用目录项目版本管理" }
: { "en-US": "Disable directory project versions", "zh-CN": "关闭目录项目版本管理" },
auth: "none",
usageArgs: "[--project <directory>]",
flags: PROJECT_FLAG,
exampleArgs: ["", "--project ./my-agent"],
async run(ctx) {
const service = createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".");
if (ctx.settings.dryRun) {
emitResult(
{ would_set_enabled: enabled, status: await service.status() },
detectOutputFormat(ctx.settings.output),
);
return;
}
const result = enabled
? await service.enable("Enable project versions")
: await service.disable();
emitResult(result, detectOutputFormat(ctx.settings.output));
},
});
}
export const managedAgentProjectVersionStatus = defineCommand({
description: {
"en-US": "Show directory project version status",
"zh-CN": "显示目录项目版本状态",
},
auth: "none",
usageArgs: "[--project <directory>]",
flags: PROJECT_FLAG,
exampleArgs: ["", "--project ./my-agent --output json"],
async run(ctx) {
emitResult(
await createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".").status(),
detectOutputFormat(ctx.settings.output),
);
},
});
export const managedAgentProjectVersionList = defineCommand({
description: { "en-US": "List directory project versions", "zh-CN": "列出目录项目版本" },
auth: "none",
usageArgs: "[--project <directory>] [--limit <n>] [--cursor <cursor>]",
flags: {
...PROJECT_FLAG,
limit: {
type: "number",
valueHint: "<n>",
description: { "en-US": "Maximum versions to return", "zh-CN": "最多返回的版本数" },
},
cursor: {
type: "string",
valueHint: "<cursor>",
description: { "en-US": "Pagination cursor", "zh-CN": "分页游标" },
},
},
exampleArgs: ["", "--limit 20 --output json"],
async run(ctx) {
emitResult(
await createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".").listVersions({
limit: ctx.flags.limit,
cursor: ctx.flags.cursor,
}),
detectOutputFormat(ctx.settings.output),
);
},
});
const VERSION_ID_FLAG = {
versionId: {
type: "string",
valueHint: "<full-version>",
required: true,
description: { "en-US": "Full project version ID", "zh-CN": "完整的项目版本 ID" },
},
} satisfies FlagsDef;
export const managedAgentProjectVersionPreview = defineCommand({
description: { "en-US": "Preview a directory project version", "zh-CN": "预览目录项目历史版本" },
auth: "none",
usageArgs: "--version-id <full-version> [--project <directory>]",
flags: { ...PROJECT_FLAG, ...VERSION_ID_FLAG },
exampleArgs: ["--version-id <full-version>"],
async run(ctx) {
emitResult(
await createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".").previewVersion(
ctx.flags.versionId,
),
detectOutputFormat(ctx.settings.output),
);
},
});
export const managedAgentProjectVersionRestore = defineCommand({
description: {
"en-US": "Restore a version to the project working directory",
"zh-CN": "将历史版本恢复到项目工作目录",
},
auth: "none",
risk: {
level: "high",
message: {
"en-US":
"This restores the full directory source to the working tree. Version history and remote State will not move.",
"zh-CN": "该操作会将完整目录源文件恢复到工作区;版本历史和远端 State 不会移动。",
},
},
usageArgs: "--version-id <full-version> [--project <directory>]",
flags: { ...PROJECT_FLAG, ...VERSION_ID_FLAG },
exampleArgs: ["--version-id <full-version>", "--version-id <full-version> --yes"],
async run(ctx) {
const service = createDirectoryWorkspaceVersionService(ctx.flags.project ?? ".");
const preview = await service.previewVersion(ctx.flags.versionId);
if (!preview.can_restore)
throw new BailianError(
preview.blockers[0] ?? preview.diagnostics[0]?.message ?? "Version cannot be restored.",
ExitCode.GENERAL,
);
if (ctx.settings.dryRun) {
emitResult(
{ would_restore: ctx.flags.versionId, preview },
detectOutputFormat(ctx.settings.output),
);
return;
}
const restored = await service.restoreVersion(ctx.flags.versionId, {
headVersion: preview.base_head_version,
projectRevision: preview.base_project_revision,
});
emitResult(restored, detectOutputFormat(ctx.settings.output));
},
});
function safeInspection(result: {
project_root: string;
project_revision: string;
source_manifest_hash: string;
yaml_hash: string;
diagnostics: unknown[];
warnings: unknown[];
organization_moves: unknown[];
canonical_yaml: string;
before_yaml?: string;
can_build?: boolean;
}) {
return {
project_root: result.project_root,
project_revision: result.project_revision,
source_manifest_hash: result.source_manifest_hash,
yaml_hash: result.yaml_hash,
diagnostics: result.diagnostics,
warnings: result.warnings,
organization_moves: result.organization_moves,
before_yaml: "before_yaml" in result ? result.before_yaml : undefined,
after_yaml: result.canonical_yaml,
can_build: "can_build" in result ? result.can_build : undefined,
};
}
@@ -0,0 +1,90 @@
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
import { launchManagedAgentPlayground } from "./_engine/playground-launcher.ts";
const PLAYGROUND_BASE_FLAGS = {
file: {
type: "string",
valueHint: "<path>",
description: {
"en-US": "Config file path (default: agents.yaml)",
"zh-CN": "配置文件路径(默认:agents.yaml)",
},
},
port: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Local port (default: 4848)",
"zh-CN": "本地端口(默认:4848)",
},
},
noOpen: {
type: "switch",
description: {
"en-US": "Do not open a browser automatically",
"zh-CN": "不自动打开浏览器",
},
},
} satisfies FlagsDef;
const PLAYGROUND_FLAGS = {
...PLAYGROUND_BASE_FLAGS,
agent: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Agent to preview (required when the project declares multiple Agents)",
"zh-CN": "要预览的 Agent(项目包含多个 Agent 时需要指定)",
},
},
} satisfies FlagsDef;
const PLAYGROUND_NOTES = [
...CREDENTIALS_NOTE,
{
"en-US":
"Session Preview requires Node.js 22+ and keeps using an agents.yaml source. Directory Workbench is available under managed-agent project workbench.",
"zh-CN":
"会话预览需要 Node.js 22+,并继续使用 agents.yaml;目录 Workbench 位于 managed-agent project workbench。",
},
];
export const managedAgentPlayground = defineCommand({
description: {
"en-US": "Launch a Session Preview for an agents.yaml Agent",
"zh-CN": "为 agents.yaml 中的 Agent 启动会话预览",
},
auth: "apiKey",
usageArgs: "[--file <path>] [--agent <id>] [--port <n>] [--no-open]",
flags: PLAYGROUND_FLAGS,
exampleArgs: ["", "--agent assistant", "--file agents.yaml --no-open"],
notes: PLAYGROUND_NOTES,
async run(ctx) {
const file = ctx.flags.file ?? "agents.yaml";
const port = ctx.flags.port ?? 4848;
if (ctx.settings.dryRun) {
emitResult(
{
would_launch: "playground",
config_file: file,
agent: ctx.flags.agent,
port,
open_browser: !ctx.flags.noOpen,
},
detectOutputFormat(ctx.settings.output),
);
return;
}
await launchManagedAgentPlayground({
file,
agent: ctx.flags.agent,
port,
open: !ctx.flags.noOpen,
surface: "preview",
client: ctx.client,
settings: ctx.settings,
});
},
});
@@ -14,9 +14,11 @@ import {
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsrContextMessages,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
parseInstantVocabulary,
type AsrApiRoute,
type AsrFlashFamily,
type OutputFormat,
@@ -73,8 +75,30 @@ const RECOGNIZE_FLAGS = {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Hot-word vocabulary ID for improved accuracy",
"zh-CN": "用于提升识别准确率的热词表 ID",
"en-US":
"Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer",
"zh-CN":
"预编译热词列表 ID(可用 `speech vocabulary create` 创建)。其 target_model 必须与 --model 完全一致,否则静默失效且无报错。支持模型比 --vocabulary 更广,含 Fun-ASR 与 Paraformer 系列",
},
},
vocabulary: {
type: "string",
valueHint: "<json>",
description: {
"en-US":
"Instant hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models",
"zh-CN":
"即时热词,JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}'。权重 1-5(推荐 4,过高会拖累其他词),50 表示超级热词。无需预先创建热词表。仅 Qwen-Audio-3.0-ASR-Flash 系列模型生效",
},
},
context: {
type: "string",
valueHint: "<text>",
description: {
"en-US":
"Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models",
"zh-CN":
"上下文增强词表,提升专有名词准确率;须包含待识别的原词本身(只写主题描述效果有限),最长 400 字符。仅 Qwen-Audio-3.0-ASR-Flash 系列与 Fun-ASR-Flash 模型生效",
},
},
channelId: {
@@ -110,9 +134,11 @@ function assertSyncFlashFlagsAllowed(
const unsupported: string[] = [];
if (flags.diarization === true) unsupported.push("--diarization");
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
unsupported.push("--vocabulary-id");
// qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape
if (flashFamily === "qwen3") {
if (flags.vocabularyId !== undefined) unsupported.push("--vocabulary-id");
if (flags.vocabulary !== undefined) unsupported.push("--vocabulary");
if (flags.context !== undefined) unsupported.push("--context");
}
if (flags.channelId !== undefined) unsupported.push("--channel-id");
if (flags.async === true) unsupported.push("--async");
@@ -121,12 +147,34 @@ function assertSyncFlashFlagsAllowed(
if (unsupported.length > 0) {
throw new BailianError(
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
syncFlashUnsupportedHint(unsupported),
ExitCode.USAGE,
);
}
}
/** Pick a hint that matches the rejected flags (vocab/context vs diarization/async/…). */
function syncFlashUnsupportedHint(unsupported: string[]): string {
const vocabularyRelated = new Set(["--vocabulary", "--vocabulary-id", "--context"]);
const hasVocabularyRelated = unsupported.some((flag) => vocabularyRelated.has(flag));
const hasOtherFlags = unsupported.some((flag) => !vocabularyRelated.has(flag));
if (hasVocabularyRelated && !hasOtherFlags) {
return (
"Hint: Use qwen-audio-3.0-asr-flash (or an async filetrans model such as " +
"qwen-audio-3.0-asr-flash-filetrans) for vocabulary/context flags."
);
}
if (hasVocabularyRelated && hasOtherFlags) {
return (
"Hint: For vocabulary/context flags use qwen-audio-3.0-asr-flash or " +
"qwen-audio-3.0-asr-flash-filetrans; for the other flags use an async filetrans model " +
"(e.g. fun-asr)."
);
}
return "Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.";
}
export default defineCommand({
description: {
"en-US": "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
@@ -141,6 +189,8 @@ export default defineCommand({
"--url https://example.com/meeting.wav --diarization --speaker-count 3",
"--url https://example.com/audio.mp3 --language zh",
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
'--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary \'{"奋斗者":4,"鲸落":4}\'',
'--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士"',
"--url https://example.com/audio.mp3 --out result.json",
"--url https://example.com/audio.mp3 --async --quiet",
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
@@ -198,6 +248,9 @@ export default defineCommand({
const format = detectOutputFormat(settings.output);
const vocabulary =
flags.vocabulary !== undefined ? parseInstantVocabulary(flags.vocabulary) : undefined;
// Auto-upload local files in parallel
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
@@ -210,6 +263,7 @@ export default defineCommand({
model,
route,
resolvedUrls[0]!,
vocabulary,
);
return;
}
@@ -223,16 +277,19 @@ export default defineCommand({
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url"
input: {
...(route.asyncInputStyle === "file_url"
? { file_url: resolvedUrls[0]! }
: { file_urls: resolvedUrls },
: { file_urls: resolvedUrls }),
...(flags.context !== undefined ? { context: buildAsrContextMessages(flags.context) } : {}),
},
parameters: {
channel_id: channelId !== undefined ? [channelId] : [0],
...languageFields,
diarization_enabled: diarization ? true : undefined,
speaker_count: speakerCount,
vocabulary_id: vocabularyId,
vocabulary,
},
};
@@ -260,6 +317,7 @@ async function handleSyncFlashMode(
model: string,
route: AsrApiRoute,
audioUrl: string,
vocabulary: Record<string, number> | undefined,
): Promise<void> {
const flashFamily = route.flashFamily as AsrFlashFamily;
const body = buildAsrFlashRequest({
@@ -267,6 +325,8 @@ async function handleSyncFlashMode(
audioUrl,
language: flags.language,
vocabularyId: flags.vocabularyId,
vocabulary,
context: flags.context,
flashFamily,
});
@@ -0,0 +1,117 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
createVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
VOCABULARY_BODY_FLAGS,
VOCABULARY_LIMIT_NOTES,
validateVocabularySource,
readVocabularyEntries,
} from "./shared.ts";
const CREATE_FLAGS = {
model: {
type: "string",
valueHint: "<model>",
description: {
"en-US":
"ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored",
"zh-CN":
"该热词表服务的 ASR 模型(必填)。必须与后续 `speech recognize` 的 --model 完全一致,否则热词表静默失效",
},
required: true,
},
prefix: {
type: "string",
valueHint: "<prefix>",
description: {
"en-US":
"Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars",
"zh-CN": "热词表自定义前缀(必填)。仅允许数字和小写字母,最长 10 个字符",
},
required: true,
},
...VOCABULARY_BODY_FLAGS,
} satisfies FlagsDef;
type CreateFlags = ParsedFlags<typeof CREATE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Create a precompiled hot-word vocabulary for ASR",
"zh-CN": "创建用于语音识别的预编译热词表",
},
auth: "apiKey",
usageArgs: "--model <model> --prefix <prefix> (--words <json> | --words-file <path>) [flags]",
flags: CREATE_FLAGS,
notes: [
{
"en-US":
"The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error.",
"zh-CN":
"--model 必须与后续 `speech recognize --vocabulary-id` 使用的 --model 完全一致;不一致时热词表会静默失效且无报错。",
},
...VOCABULARY_LIMIT_NOTES,
],
exampleArgs: [
{
"en-US": '--model fun-asr --prefix demo --words \'{"Fendouzhe":4,"Jingluo":4}\'',
"zh-CN": '--model fun-asr --prefix demo --words \'{"奋斗者":4,"鲸落":4}\'',
},
{
"en-US":
'--model paraformer-v2 --prefix demo --words \'[{"text":"Fendouzhe","weight":4,"lang":"zh"}]\'',
"zh-CN":
'--model paraformer-v2 --prefix demo --words \'[{"text":"奋斗者","weight":4,"lang":"zh"}]\'',
},
{
"en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --lang zh",
"zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --lang zh",
},
"--model fun-asr --prefix demo --words-file ./hotwords.json",
{
"en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --quiet",
"zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --quiet",
},
],
validate: (flags: CreateFlags) => validateVocabularySource(flags),
async run(ctx) {
const { settings, flags } = ctx;
const vocabulary = readVocabularyEntries(flags);
const format = detectOutputFormat(settings.output);
const request = buildVocabularyRequest("create_vocabulary", {
target_model: flags.model,
prefix: flags.prefix,
vocabulary,
});
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request,
},
format,
);
return;
}
const response = await createVocabulary(ctx.client, {
targetModel: flags.model,
prefix: flags.prefix,
vocabulary,
});
if (settings.quiet || format === "text") {
emitBare(response.output?.vocabulary_id ?? "");
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,60 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
deleteVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOCABULARY_ID_FLAG } from "./shared.ts";
const DELETE_FLAGS = {
...VOCABULARY_ID_FLAG,
} satisfies FlagsDef;
type DeleteFlags = ParsedFlags<typeof DELETE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Delete a precompiled hot-word vocabulary",
"zh-CN": "删除预编译热词表",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This permanently deletes the specified hot-word vocabulary and cannot be undone.",
"zh-CN": "该操作会永久删除指定的热词表,且无法撤销。",
},
},
usageArgs: "--id <id>",
flags: DELETE_FLAGS,
exampleArgs: ["--id vocab-demo-xxx --dry-run", "--id vocab-demo-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = (flags as DeleteFlags).id;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("delete_vocabulary", {
vocabulary_id: vocabularyId,
}),
},
format,
);
return;
}
const response = await deleteVocabulary(ctx.client, vocabularyId);
if (settings.quiet || format === "text") {
emitBare(vocabularyId);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,79 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
queryVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { VOCABULARY_ID_FLAG } from "./shared.ts";
const GET_FLAGS = {
...VOCABULARY_ID_FLAG,
} satisfies FlagsDef;
type GetFlags = ParsedFlags<typeof GET_FLAGS>;
export default defineCommand({
description: {
"en-US": "Get details of a precompiled hot-word vocabulary",
"zh-CN": "查看预编译热词表详情",
},
auth: "apiKey",
usageArgs: "--id <id>",
flags: GET_FLAGS,
notes: [
{
"en-US":
"Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure.",
"zh-CN":
"调用 `speech recognize --vocabulary-id` 前请用本命令确认 target_model;模型不一致会导致静默失效。",
},
],
exampleArgs: ["--id vocab-demo-xxx", "--id vocab-demo-xxx --quiet"],
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = (flags as GetFlags).id;
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("query_vocabulary", {
vocabulary_id: vocabularyId,
}),
},
format,
);
return;
}
const response = await queryVocabulary(ctx.client, vocabularyId);
const output = response.output;
if (settings.quiet) {
emitBare(output?.target_model ?? "");
return;
}
if (format === "text") {
emitBare(`vocabulary_id: ${vocabularyId}`);
emitBare(`status: ${output?.status ?? ""}`);
emitBare(`target_model: ${output?.target_model ?? ""}`);
emitBare(`gmt_create: ${output?.gmt_create ?? ""}`);
emitBare(`gmt_modified: ${output?.gmt_modified ?? ""}`);
const entries = output?.vocabulary ?? [];
if (entries.length > 0) {
emitBare("vocabulary:");
for (const entry of entries) {
const langPart = entry.lang ? ` lang=${entry.lang}` : "";
emitBare(` ${entry.text} weight=${entry.weight}${langPart}`);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,116 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
listVocabularies,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
const LIST_FLAGS = {
prefix: {
type: "string",
valueHint: "<prefix>",
description: {
"en-US": "Filter by vocabulary prefix",
"zh-CN": "按热词表前缀过滤",
},
},
page: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1",
"zh-CN": "页码,从 1 开始(默认:1)。映射为 API 的 page_index(从 0 开始):page - 1",
},
},
pageSize: {
type: "number",
valueHint: "<n>",
description: {
"en-US": "Results per page (default: 10)",
"zh-CN": "每页结果数(默认:10)",
},
},
} satisfies FlagsDef;
type ListFlags = ParsedFlags<typeof LIST_FLAGS>;
export default defineCommand({
description: {
"en-US": "List precompiled hot-word vocabularies",
"zh-CN": "列出预编译热词表",
},
auth: "apiKey",
usageArgs: "[--prefix <prefix>] [--page <n>] [--page-size <n>]",
flags: LIST_FLAGS,
notes: [
{
"en-US":
"List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for.",
"zh-CN": "list 响应不含 target_model;要对齐模型请使用 `speech vocabulary get`。",
},
{
"en-US": "Vocabularies with status UNDEPLOYED are silently ignored by ASR.",
"zh-CN": "status 为 UNDEPLOYED 的热词表会被 ASR 静默忽略。",
},
],
exampleArgs: ["", "--prefix demo", "--page 2 --page-size 20"],
validate: (flags: ListFlags) => {
if (flags.page !== undefined && flags.page < 1) {
return "--page must be >= 1.";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const format = detectOutputFormat(settings.output);
const pageIndex = flags.page !== undefined ? flags.page - 1 : undefined;
const input: Record<string, unknown> = {};
if (flags.prefix !== undefined) input.prefix = flags.prefix;
if (pageIndex !== undefined) input.page_index = pageIndex;
if (flags.pageSize !== undefined) input.page_size = flags.pageSize;
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request: buildVocabularyRequest("list_vocabulary", input),
},
format,
);
return;
}
const response = await listVocabularies(ctx.client, {
prefix: flags.prefix,
pageIndex,
pageSize: flags.pageSize,
});
if (settings.quiet || format === "text") {
const items = response.output?.vocabulary_list ?? [];
if (items.length === 0) {
emitBare("No vocabularies found.");
} else {
let hasUndeployed = false;
for (const item of items) {
const id = item.vocabulary_id ?? "";
const status = item.status ?? "";
const modified = item.gmt_modified ?? "";
if (status === "UNDEPLOYED") hasUndeployed = true;
emitBare(`[${id}] ${status} ${modified}`.trimEnd());
}
if (hasUndeployed) {
emitBare(
"Note: UNDEPLOYED vocabularies are silently ignored by ASR. Use `speech vocabulary get` to inspect them.",
);
}
}
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,103 @@
import {
readTextFromPathOrStdin,
parseVocabularyEntries,
UsageError,
type FlagsDef,
type ParsedFlags,
type VocabularyEntry,
} from "bailian-cli-core";
/** Shared --id flag for get / update / delete. */
export const VOCABULARY_ID_FLAG = {
id: {
type: "string",
valueHint: "<id>",
description: {
"en-US": "Hot-word vocabulary ID (required)",
"zh-CN": "热词表 ID(必填)",
},
required: true,
},
} satisfies FlagsDef;
/** Shared hot-word body flags for create / update. */
export const VOCABULARY_BODY_FLAGS = {
words: {
type: "string",
valueHint: "<json>",
description: {
"en-US":
"Hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file",
"zh-CN":
"热词,JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}';需要逐条指定语言时可传 API 的条目数组。权重 1-5(推荐 4);当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50(超级热词)。也可使用 --words-file",
},
},
wordsFile: {
type: "string",
valueHint: "<path>",
description: {
"en-US": "JSON file with the hot words (use - for stdin)",
"zh-CN": "包含热词的 JSON 文件(使用 - 从 stdin 读取)",
},
},
lang: {
type: "string",
valueHint: "<code>",
description: {
"en-US":
"Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja",
"zh-CN":
"对象形态时应用到所有热词的语言代码(选填;数组形态忽略)。Paraformer 支持 zh/en/ja/yue/ko/de/fr/ru;Fun-ASR 支持 zh/en/ja",
},
},
} satisfies FlagsDef;
type VocabularySourceFlags = ParsedFlags<typeof VOCABULARY_BODY_FLAGS>;
/** Cross-flag validation for --words / --words-file. */
export function validateVocabularySource(flags: VocabularySourceFlags): string | undefined {
if (!flags.words && !flags.wordsFile) {
return "Provide --words or --words-file.";
}
if (flags.words && flags.wordsFile) {
return "Use either --words or --words-file, not both.";
}
if (!flags.words) {
return undefined;
}
try {
parseVocabularyEntries(flags.words, flags.lang);
} catch (error) {
if (error instanceof UsageError) {
return error.message;
}
throw error;
}
return undefined;
}
/** Read and parse vocabulary entries from flag or file. */
export function readVocabularyEntries(flags: VocabularySourceFlags): VocabularyEntry[] {
const raw = flags.wordsFile ? readTextFromPathOrStdin(flags.wordsFile) : (flags.words as string);
return parseVocabularyEntries(raw, flags.lang);
}
/** Shared notes covering account limits and silent-failure pitfalls. */
export const VOCABULARY_LIMIT_NOTES = [
{
"en-US":
"Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.",
"zh-CN": "每个账号最多 10 个热词表;两次更新间隔至少 5 分钟。完整限制见 improve-asr-accuracy。",
},
{
"en-US":
"Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.",
"zh-CN": "新加坡子业务空间不支持热词表;服务端错误会原样透传。",
},
{
"en-US":
"Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.",
"zh-CN":
"权重 1-5(推荐 4);当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50(超级热词)。",
},
] as const;
@@ -0,0 +1,90 @@
import {
defineCommand,
detectOutputFormat,
speechVocabularyPath,
buildVocabularyRequest,
updateVocabulary,
type FlagsDef,
type ParsedFlags,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
VOCABULARY_ID_FLAG,
VOCABULARY_BODY_FLAGS,
VOCABULARY_LIMIT_NOTES,
validateVocabularySource,
readVocabularyEntries,
} from "./shared.ts";
const UPDATE_FLAGS = {
...VOCABULARY_ID_FLAG,
...VOCABULARY_BODY_FLAGS,
} satisfies FlagsDef;
type UpdateFlags = ParsedFlags<typeof UPDATE_FLAGS>;
export default defineCommand({
description: {
"en-US": "Replace the contents of a precompiled hot-word vocabulary",
"zh-CN": "完全替换预编译热词表的内容",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone.",
"zh-CN": "该操作会完全替换热词表中的全部词条。未列出的词将被丢弃,且无法撤销。",
},
},
usageArgs: "--id <id> (--words <json> | --words-file <path>) [flags]",
flags: UPDATE_FLAGS,
notes: [
{
"en-US":
"update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written.",
"zh-CN": "update 是完全替换,不是增量追加。建议先用 --dry-run 预览将要写入的完整词表。",
},
...VOCABULARY_LIMIT_NOTES,
],
exampleArgs: [
{
"en-US": "--id vocab-demo-xxx --words '{\"Fendouzhe\":4}' --dry-run",
"zh-CN": "--id vocab-demo-xxx --words '{\"奋斗者\":4}' --dry-run",
},
{
"en-US": '--id vocab-demo-xxx --words \'{"Fendouzhe":4,"Jingluo":4}\' --yes',
"zh-CN": '--id vocab-demo-xxx --words \'{"奋斗者":4,"鲸落":4}\' --yes',
},
],
validate: (flags: UpdateFlags) => validateVocabularySource(flags),
async run(ctx) {
const { settings, flags } = ctx;
const vocabularyId = flags.id;
const vocabulary = readVocabularyEntries(flags);
const format = detectOutputFormat(settings.output);
const request = buildVocabularyRequest("update_vocabulary", {
vocabulary_id: vocabularyId,
vocabulary,
});
if (settings.dryRun) {
emitResult(
{
endpoint: ctx.client.url(speechVocabularyPath()),
request,
},
format,
);
return;
}
const response = await updateVocabulary(ctx.client, vocabularyId, vocabulary);
if (settings.quiet || format === "text") {
emitBare(vocabularyId);
} else {
emitResult(response, format);
}
},
});
+1 -1
View File
@@ -197,7 +197,7 @@ export default defineCommand({
// --- Build request body ---
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const body: DashScopeVideoEditRequest = {
model,
@@ -208,7 +208,7 @@ export default defineCommand({
resolvedFileUrl = await ctx.client.uploadFile(fileUrl, model);
}
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const body: DashScopeVideoRequest = {
+1 -1
View File
@@ -249,7 +249,7 @@ export default defineCommand({
// --- Build request body ---
const promptExtend = resolveBooleanFlag(flags.promptExtend, undefined, "prompt-extend");
const watermark = resolveWatermark(flags.watermark);
const watermark = resolveWatermark(flags.watermark, settings.watermark);
const body: DashScopeVideoRefRequest = {
model,
+19
View File
@@ -73,6 +73,11 @@ export { default as mcpTools } from "./commands/mcp/tools.ts";
export { default as searchWeb } from "./commands/search/web.ts";
export { default as speechSynthesize } from "./commands/speech/synthesize.ts";
export { default as speechRecognize } from "./commands/speech/recognize.ts";
export { default as speechVocabularyCreate } from "./commands/speech/vocabulary/create.ts";
export { default as speechVocabularyList } from "./commands/speech/vocabulary/list.ts";
export { default as speechVocabularyGet } from "./commands/speech/vocabulary/get.ts";
export { default as speechVocabularyUpdate } from "./commands/speech/vocabulary/update.ts";
export { default as speechVocabularyDelete } from "./commands/speech/vocabulary/delete.ts";
export { default as fileUpload } from "./commands/file/upload.ts";
export { default as consoleCall } from "./commands/console/call.ts";
export { default as usageFree } from "./commands/usage/free.ts";
@@ -137,6 +142,20 @@ export { default as managedAgentValidate } from "./commands/managed-agent/valida
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
export { managedAgentPlayground } from "./commands/managed-agent/workbench.ts";
export {
managedAgentProjectBuild,
managedAgentProjectInit,
managedAgentProjectPublish,
managedAgentProjectValidate,
managedAgentProjectVersionDisable,
managedAgentProjectVersionEnable,
managedAgentProjectVersionList,
managedAgentProjectVersionPreview,
managedAgentProjectVersionRestore,
managedAgentProjectVersionStatus,
managedAgentProjectWorkbench,
} from "./commands/managed-agent/project.ts";
export { default as managedAgentStateList } from "./commands/managed-agent/state-list.ts";
export { default as managedAgentStateShow } from "./commands/managed-agent/state-show.ts";
export { default as managedAgentStateRm } from "./commands/managed-agent/state-rm.ts";
@@ -33,3 +33,9 @@ test("default-speech-recognition-model alias accepts an ASR model ID", () => {
"qwen-audio-3.0-asr-flash",
);
});
test("watermark config accepts only boolean text and stores a boolean", () => {
expect(validateAndCoerce("watermark", "false")).toBe(false);
expect(validateAndCoerce("watermark", "TRUE")).toBe(true);
expect(() => validateAndCoerce("watermark", "yes")).toThrow(/true or false/i);
});
@@ -200,8 +200,10 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
expect(res.json.keys).toContain("console_site");
expect(res.json.keys).toContain("telemetry");
expect(res.json.keys).toContain("default_speech_recognition_model");
expect(res.json.keys).toContain("watermark");
expect(res.json.enums.console_site).toEqual(["domestic", "international"]);
expect(res.json.booleanKeys).toContain("telemetry");
expect(res.json.booleanKeys).toContain("watermark");
// Default field hints are surfaced as prefilled values in the UI.
expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-3.0");
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.8-max");
@@ -104,6 +104,20 @@ test("inject:base_url 已带后缀不重复拼;非空字面量 base_url 保留",
expect(literal.bailian.base_url).toBe("https://custom.example.com/api/v1/agentstudio");
});
test("inject:目录项目可强制使用 Bailian CLI 解析出的 base_url", () => {
const providers = {
bailian: {
api_key: "from-project-env",
base_url: "https://project-env.example.com/api/v1/agentstudio",
},
};
injectProviderCredentials(providers, makeHost({ apiCred: bailianCred() }), {
overrideBaseUrl: true,
});
expect(providers.bailian.api_key).toBe("sk-auth-chain");
expect(providers.bailian.base_url).toBe("https://dashscope.aliyuncs.com/api/v1/agentstudio");
});
test("inject:base_url 尾斜杠被规范化,不产生双斜杠", () => {
const providers = { bailian: { api_key: "", base_url: "" } };
injectProviderCredentials(
@@ -64,10 +64,53 @@ describe("e2e: config", () => {
config_file?: string;
base_url?: string;
timeout?: number;
watermark?: boolean;
}>(stdout);
expect(data.config_file).toBeDefined();
expect(data.base_url).toBeDefined();
expect(data.timeout).toBeDefined();
expect(data.watermark).toBe(true);
});
test("config set 将 watermark 作为 boolean 写入并由 config show 读回", async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-config-watermark-"));
try {
const env = { BAILIAN_CONFIG_DIR: configDir };
const setResult = await runCommandE2e(
CONFIG_ROUTES,
[
"config",
"set",
"--config",
"media",
"--key",
"watermark",
"--value",
"false",
"--output",
"json",
],
env,
);
expect(setResult.exitCode, setResult.stderr).toBe(0);
expect(parseStdoutJson<{ watermark?: boolean }>(setResult.stdout).watermark).toBe(false);
const persisted = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
string,
Record<string, unknown>
>;
expect(persisted.media?.watermark).toBe(false);
const showResult = await runCommandE2e(
CONFIG_ROUTES,
["config", "show", "--config", "media", "--output", "json"],
env,
);
expect(showResult.exitCode, showResult.stderr).toBe(0);
expect(parseStdoutJson<{ watermark?: boolean }>(showResult.stdout).watermark).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
test("config show --output text", async () => {
@@ -0,0 +1,112 @@
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runNodeMain } from "e2e/runner";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson } from "./helpers.ts";
const directories: string[] = [];
async function temporaryDirectory() {
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-yaml-init-")));
directories.push(directory);
return directory;
}
afterEach(async () => {
for (const directory of directories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
async function runInit(directory: string, args: string[] = []) {
return runNodeMain(
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
["managed-agent", "init", ...args],
{
cwd: directory,
env: {
BAILIAN_CONFIG_DIR: await temporaryDirectory(),
BAILIAN_E2E_ROUTES: JSON.stringify([
{ path: "managed-agent init", export: "managedAgentInit" },
]),
},
},
);
}
describe("e2e: managed-agent init output path", () => {
test.each(["default", "relative", "absolute"] as const)(
"reports the absolute YAML path for a %s output path",
async (pathKind) => {
const directory = await temporaryDirectory();
const relativePath =
pathKind === "default" ? "agents.yaml" : join("config files", "custom agents.yaml");
const outputPath = join(directory, relativePath);
await mkdir(dirname(outputPath), { recursive: true });
const args =
pathKind === "default"
? []
: ["--file", pathKind === "absolute" ? outputPath : relativePath];
const result = await runInit(directory, args);
expect(result.exitCode, result.stderr).toBe(0);
expect(result.stdout.split(/\r?\n/)[0]).toBe(`Created ${outputPath}`);
expect(await readFile(outputPath, "utf8")).toContain("agents:");
},
);
test("preserves the JSON output contract", async () => {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--file", "custom.yaml", "--output", "json"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({
created: "custom.yaml",
provider: "bailian",
agent: "assistant",
});
expect((await stat(join(directory, "custom.yaml"))).isFile()).toBe(true);
});
test("keeps dry-run read-only without reporting a created file", async () => {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--dry-run", "--output", "json"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({
would_create: "agents.yaml",
provider: "bailian",
agent: "assistant",
would_update_gitignore: true,
});
expect(await stat(join(directory, "agents.yaml")).catch(() => null)).toBeNull();
expect(await stat(join(directory, ".gitignore")).catch(() => null)).toBeNull();
});
test.each(["default", "relative", "absolute"] as const)(
"reports the absolute existing %s path without overwriting the YAML",
async (pathKind) => {
const directory = await temporaryDirectory();
const relativePath =
pathKind === "default" ? "agents.yaml" : join("config files", "custom agents.yaml");
const outputPath = join(directory, relativePath);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, "Keep user configuration.\n");
const args =
pathKind === "default"
? []
: ["--file", pathKind === "absolute" ? outputPath : relativePath];
const result = await runInit(directory, args);
expect(result.exitCode).toBe(2);
expect(result.stderr).toContain(`${outputPath} already exists.`);
expect(result.stderr).toContain("Pass --force to overwrite.");
expect(result.stdout).not.toContain("Created ");
expect(await readFile(outputPath, "utf8")).toBe("Keep user configuration.\n");
},
);
});
@@ -0,0 +1,247 @@
import {
cp,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { runNodeMain } from "e2e/runner";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandHelp } from "./helpers.ts";
const routes = {
"managed-agent project init": "managedAgentProjectInit",
"managed-agent project build": "managedAgentProjectBuild",
"managed-agent project publish": "managedAgentProjectPublish",
"managed-agent project validate": "managedAgentProjectValidate",
} as const;
const directories: string[] = [];
async function temporaryDirectory() {
const directory = await realpath(await mkdtemp(join(tmpdir(), "bailian-project-init-")));
directories.push(directory);
return directory;
}
afterEach(async () => {
for (const directory of directories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
function runInit(directory: string, args: string[] = []) {
return runProject(directory, "init", args);
}
async function runProject(directory: string, subcommand: string, args: string[] = [], json = true) {
const configRoot = await temporaryDirectory();
return runNodeMain(
fileURLToPath(new URL("./harness/main.ts", import.meta.url)),
["managed-agent", "project", subcommand, ...args, ...(json ? ["--output", "json"] : [])],
{
cwd: directory,
env: {
BAILIAN_CONFIG_DIR: configRoot,
BAILIAN_E2E_ROUTES: JSON.stringify(
Object.entries(routes).map(([path, exportName]) => ({ path, export: exportName })),
),
},
},
);
}
describe("e2e: managed-agent project init directory defaults", () => {
test("build links copied resources and reports ambiguous environment bindings without writing", async () => {
const directory = await temporaryDirectory();
const initialized = await runInit(directory);
expect(initialized.exitCode, initialized.stderr).toBe(0);
const root = join(directory, "managed-agent");
for (const [section, id] of [
["skills", "example-skill"],
["files", "example-file"],
["environments", "example-env"],
["vaults", "example-vault"],
] as const) {
await cp(
join(root, "agents/assistant", section, "_examples", id),
join(root, "agents/assistant", section, id),
{ recursive: true },
);
}
const agentPath = join(root, "agents/assistant/agent.json");
const original = await readFile(agentPath, "utf8");
const preview = await runProject(root, "build", ["--dry-run"]);
expect(preview.exitCode, preview.stderr).toBe(0);
expect(parseStdoutJson<{ can_build: boolean }>(preview.stdout).can_build).toBe(true);
expect(await readFile(agentPath, "utf8")).toBe(original);
const built = await runProject(root, "build");
expect(built.exitCode, built.stderr).toBe(0);
const agent = JSON.parse(await readFile(agentPath, "utf8"));
expect(agent).toMatchObject({
environment: "example-env",
vault: "example-vault",
skills: ["example-skill"],
files: [{ file: "example-file", mount_path: "/mnt/example.md" }],
});
const alternatePath = join(root, "agents/assistant/environments/alternate");
await mkdir(alternatePath);
await writeFile(
join(alternatePath, "environment.json"),
JSON.stringify({ id: "alternate", config: { type: "cloud" } }),
);
delete agent.environment;
await writeFile(agentPath, JSON.stringify(agent));
const beforeConflict = await readFile(agentPath, "utf8");
const buildPath = join(root, ".openagentpack/build/agents.yaml");
const beforeBuild = await readFile(buildPath, "utf8");
for (const json of [false, true]) {
const conflict = await runProject(root, "build", [], json);
expect(conflict.exitCode).toBe(1);
expect(conflict.stderr).toContain("multiple local environment resources");
expect(conflict.stderr).toContain("Set 'environment' explicitly");
expect(conflict.stderr).not.toMatch(/\p{Script=Han}/u);
}
expect(await readFile(agentPath, "utf8")).toBe(beforeConflict);
expect(await readFile(buildPath, "utf8")).toBe(beforeBuild);
});
test("build checks directories and writes without confirmation while publish stays gated", async () => {
const directory = await temporaryDirectory();
const initialized = await runInit(directory);
expect(initialized.exitCode, initialized.stderr).toBe(0);
const root = join(directory, "managed-agent");
const nested = join(root, "agents/assistant/skills");
for (const args of [[], ["--dry-run"]]) {
const result = await runProject(nested, "build", args, false);
expect(result.exitCode, result.stderr).toBe(2);
expect(result.stderr).toContain("Not a project root:");
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
expect(result.stderr).toContain(`cd '${root}'`);
expect(result.stderr).not.toContain("high-risk");
expect(result.stderr).not.toContain("Usage:");
}
const explicit = await runProject(root, "build", ["--project", nested]);
expect(explicit.exitCode).toBe(2);
expect(explicit.stderr).toContain(`--project '${root}'`);
const noMarker = await runProject(directory, "build", [], false);
expect(noMarker.exitCode).toBe(2);
expect(noMarker.stderr).toContain("project.json");
expect(noMarker.stderr).not.toMatch(/\p{Script=Han}/u);
expect(noMarker.stderr).not.toContain("high-risk");
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
const preview = await runProject(root, "build", ["--dry-run"]);
expect(preview.exitCode, preview.stderr).toBe(0);
expect(await stat(join(root, ".openagentpack/build")).catch(() => null)).toBeNull();
const built = await runProject(root, "build");
expect(built.exitCode, built.stderr).toBe(0);
expect(built.stderr).not.toContain("requires_confirmation");
expect((await stat(join(root, ".openagentpack/build/agents.yaml"))).isFile()).toBe(true);
const explicitValid = await runProject(nested, "build", ["--project", root]);
expect(explicitValid.exitCode, explicitValid.stderr).toBe(0);
const storePath = join(root, ".openagentpack/versions/project/store.json");
const beforePublish = await readFile(storePath, "utf8");
const publish = await runProject(root, "publish");
expect(publish.exitCode).toBe(7);
expect(publish.stderr).toContain("requires_confirmation");
expect(await readFile(storePath, "utf8")).toBe(beforePublish);
});
test("only publish help includes confirmation; build keeps dry-run", async () => {
const build = await runCommandHelp(routes, ["managed-agent", "project", "build", "--help"]);
expect(build.stderr).not.toContain("--yes");
expect(build.stderr).toContain("--dry-run");
const publish = await runCommandHelp(routes, ["managed-agent", "project", "publish", "--help"]);
expect(publish.stderr).toContain("--yes");
const directory = await temporaryDirectory();
const removedFlag = await runProject(directory, "build", ["--yes"]);
expect(removedFlag.exitCode).toBe(2);
expect(removedFlag.stderr).toMatch(/Unknown flag.*--yes/);
});
test("nested build and validate explain the project root without changing directories", async () => {
const directory = await temporaryDirectory();
const initialized = await runInit(directory);
expect(initialized.exitCode, initialized.stderr).toBe(0);
const root = join(directory, "managed-agent");
const nested = join(root, "agents/assistant/skills");
for (const command of ["build", "validate"]) {
const result = await runProject(nested, command, ["--dry-run"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("Not a project root:");
expect(result.stderr).not.toMatch(/\p{Script=Han}/u);
expect(result.stderr).toContain(`cd '${root}'`);
expect(result.stderr).toContain(`--project '${root}'`);
expect(result.stderr).not.toContain("ERR_MODULE_NOT_FOUND");
}
expect(await stat(join(nested, ".openagentpack")).catch(() => null)).toBeNull();
const corrected = await runProject(nested, "build", ["--project", root, "--dry-run"]);
expect(corrected.exitCode, corrected.stderr).toBe(0);
});
test("help describes the subdirectory default and explicit in-place initialization", async () => {
const result = await runCommandHelp(routes, ["managed-agent", "project", "init", "--help"]);
expect(result.exitCode).toBe(0);
expect(result.stderr).toContain("./managed-agent");
expect(result.stderr).toContain("--project .");
});
test("default dry-run reports the child directory without creating it", async () => {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--dry-run"]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson(result.stdout)).toEqual({ would_initialize_project: "./managed-agent" });
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
});
test("initializes only the child, ignores parent source, and rejects repeated init without overwriting", async () => {
const directory = await temporaryDirectory();
const parentYaml = "not a valid project declaration";
await writeFile(join(directory, "agents.yaml"), parentYaml);
if (process.platform !== "win32") {
await symlink("missing-instructions.md", join(directory, "CLAUDE.md"));
}
const result = await runInit(directory);
expect(result.exitCode, result.stderr).toBe(0);
const projectRoot = join(directory, "managed-agent");
const initialized = parseStdoutJson<{
project_root: string;
baseline_version: string;
converted_from_yaml: boolean;
}>(result.stdout);
expect(initialized.project_root).toBe(projectRoot);
expect(initialized.baseline_version).toHaveLength(64);
expect(initialized.converted_from_yaml).toBe(false);
expect(await stat(join(directory, "project.json")).catch(() => null)).toBeNull();
expect(await readFile(join(directory, "agents.yaml"), "utf8")).toBe(parentYaml);
expect((await stat(join(projectRoot, "agents/assistant/agent.json"))).isFile()).toBe(true);
expect(
(await stat(join(projectRoot, ".openagentpack/versions/project/store.json"))).isFile(),
).toBe(true);
const instructions = join(projectRoot, "agents/assistant/instructions.md");
await writeFile(instructions, "user changes");
const repeated = await runInit(directory);
expect(repeated.exitCode).not.toBe(0);
expect(repeated.stderr).toContain("already exists");
expect(await readFile(instructions, "utf8")).toBe("user changes");
});
test("explicit paths, including dot, remain exact project roots", async () => {
for (const target of ["custom-agent", "."]) {
const directory = await temporaryDirectory();
const result = await runInit(directory, ["--project", target]);
expect(result.exitCode, result.stderr).toBe(0);
expect(parseStdoutJson<{ project_root: string }>(result.stdout).project_root).toBe(
join(directory, target),
);
expect(await stat(join(directory, "managed-agent")).catch(() => null)).toBeNull();
}
});
});
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
@@ -9,7 +9,7 @@ import {
resolveProjectConfig,
type ResourceAddress,
} from "@openagentpack/sdk";
import { describe, expect, test } from "vite-plus/test";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { parse } from "yaml";
import { e2eFixturesDir, parseStdoutJson, runCommandHelp, runCommandE2e } from "./helpers.ts";
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
@@ -29,6 +29,13 @@ const DEPLOYMENT_SAFETY_DIAGNOSTIC_CODES = [
"bailian.deployment.file.mount_path.required",
"bailian.deployment.file.mount_path.duplicate",
];
const projectDirectories: string[] = [];
afterEach(async () => {
for (const directory of projectDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
async function writeLocalSkillDirectory(parentDirectory: string): Promise<string> {
const skillDirectory = join(parentDirectory, "local-report");
@@ -98,8 +105,8 @@ async function seedTrackedResources(
/**
* managed-agent:help / 缺参不依赖密钥;所有 mutation 命令的 --dry-run
* 必须在构建 SDK runtime(凭证注入 / 联网 / 写盘)之前短路,因此同样不需要密钥。
* 鉴权分层:离线命令(init/validate/state list|show|rm)auth: "none";联网命令
* 统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。
* 鉴权分层:离线命令(init/project init/validate/state list|show|rm)auth: "none";
* 联网命令统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。
* 真实集成(apply/destroy/session 流程)依赖工作区内的 agents.yaml 与远端资源,
* 属批量场景,暂仅覆盖 dry-run 契约。
*/
@@ -226,6 +233,215 @@ describe("e2e: managed-agent", () => {
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--file|--yes/i);
expect(stderr).not.toContain("--provider");
expect(stderr).not.toContain("--ci");
expect(stderr).not.toContain("--refresh-only");
});
test("managed-agent apply 不再接受 --refresh-only", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"apply",
"--dry-run",
"--refresh-only",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/Unknown flag.*--refresh-only/i);
});
test("managed-agent init 不再暴露 Git 仓库脚手架", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"init",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).not.toContain("--git");
});
test("managed-agent project 暴露目录项目与共享版本管理子命令", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/init|validate|build|publish|workbench|version/i);
});
test("managed-agent project 完成 init、validate、build 与 version status 本地闭环", async () => {
const projectRoot = await mkdtemp(join(tmpdir(), "bailian-managed-agent-project-"));
projectDirectories.push(projectRoot);
const initialized = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"init",
"--project",
projectRoot,
"--output",
"json",
]);
expect(initialized.exitCode, initialized.stderr).toBe(0);
expect(
parseStdoutJson<{ baseline_version?: string }>(initialized.stdout).baseline_version,
).toMatch(/^[a-f0-9]{64}$/);
expect(JSON.parse(await readFile(join(projectRoot, "project.json"), "utf8"))).toEqual({
version: "1",
});
expect(await stat(join(projectRoot, ".env")).catch(() => null)).toBeNull();
expect(await stat(join(projectRoot, ".gitignore")).catch(() => null)).toBeNull();
expect(
JSON.parse(await readFile(join(projectRoot, "agents/assistant/agent.json"), "utf8")),
).toEqual({ name: "Assistant", model: "qwen3.7-max" });
for (const relativePath of [
"skills/_examples/example-skill/skill.json",
"skills/_examples/example-skill/SKILL.md",
"files/_examples/example-file/file.json",
"files/_examples/example-file/example.md",
"vaults/_examples/example-vault/vault.json",
"environments/_examples/example-env/environment.json",
]) {
expect((await stat(join(projectRoot, "agents/assistant", relativePath))).isFile()).toBe(true);
expect(
await readFile(join(projectRoot, "agents/assistant", relativePath, "../README.md"), "utf8"),
).toContain("_examples/");
}
const skillDirectory = join(projectRoot, "agents/assistant/skills/writer");
const filesDirectory = join(projectRoot, "agents/assistant/files");
const nestedFileDirectory = join(filesDirectory, "mount");
await mkdir(skillDirectory, { recursive: true });
await mkdir(nestedFileDirectory, { recursive: true });
await writeFile(join(skillDirectory, "SKILL.md"), "# Writer\n", "utf8");
await writeFile(join(filesDirectory, "brief.txt"), "Build this brief.\n", "utf8");
await writeFile(join(nestedFileDirectory, "mount.md"), "Mount this file.\n", "utf8");
const validated = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"validate",
"--project",
projectRoot,
"--output",
"json",
]);
expect(validated.exitCode, validated.stderr).toBe(0);
expect(
parseStdoutJson<{ diagnostics?: Array<{ severity?: string }> }>(validated.stdout).diagnostics,
).not.toContainEqual(expect.objectContaining({ severity: "error" }));
const vaultDirectory = join(projectRoot, "agents/assistant/vaults/secrets");
await mkdir(vaultDirectory, { recursive: true });
await writeFile(
join(vaultDirectory, "vault.json"),
JSON.stringify({
id: "secrets",
display_name: "Secrets",
credentials: [
{
name: "service",
type: "environment_variable",
secret_name: "SERVICE_TOKEN",
secret_value: "e2e-vault-private-value",
},
],
}),
);
const dryBuild = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"build",
"--project",
projectRoot,
"--dry-run",
"--output",
"json",
]);
expect(dryBuild.exitCode, dryBuild.stderr).toBe(0);
expect(dryBuild.stdout + dryBuild.stderr).not.toContain("e2e-vault-private-value");
expect(dryBuild.stdout).toContain("AGENTS_VAULT_");
expect(await stat(join(projectRoot, ".env")).catch(() => null)).toBeNull();
const built = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"build",
"--project",
projectRoot,
"--output",
"json",
]);
expect(built.exitCode, built.stderr).toBe(0);
const generatedYaml = await readFile(
join(projectRoot, ".openagentpack/build/agents.yaml"),
"utf8",
);
expect(generatedYaml).not.toContain("example-");
expect(generatedYaml).not.toContain("_examples");
expect(generatedYaml).not.toContain("$" + "{SERVICE_TOKEN}");
expect(built.stdout + built.stderr).not.toContain("e2e-vault-private-value");
expect(await readFile(join(projectRoot, ".env"), "utf8")).toContain("e2e-vault-private-value");
expect(await readFile(join(vaultDirectory, "vault.json"), "utf8")).not.toContain(
"e2e-vault-private-value",
);
expect(
await readFile(join(projectRoot, ".openagentpack/build/agents.yaml"), "utf8"),
).not.toContain("e2e-vault-private-value");
expect(await readFile(join(projectRoot, ".openagentpack/build/agents.yaml"), "utf8")).toContain(
"assistant",
);
expect(await readFile(join(projectRoot, ".openagentpack/build/agents.yaml"), "utf8")).toContain(
"provider: bailian",
);
expect(JSON.parse(await readFile(join(skillDirectory, "skill.json"), "utf8"))).toEqual({
id: "writer",
});
expect(JSON.parse(await readFile(join(filesDirectory, "brief/file.json"), "utf8"))).toEqual({
id: "brief",
name: "brief.txt",
source: "./brief.txt",
});
expect(JSON.parse(await readFile(join(nestedFileDirectory, "file.json"), "utf8"))).toEqual({
id: "mount",
name: "mount.md",
source: "./mount.md",
});
const builtAgent = JSON.parse(
await readFile(join(projectRoot, "agents/assistant/agent.json"), "utf8"),
);
expect(builtAgent.skills).toEqual(["writer"]);
expect(builtAgent.files).toEqual(
expect.arrayContaining([
{ file: "brief", mount_path: "/mnt/brief.txt" },
{ file: "mount", mount_path: "/mnt/mount.md" },
]),
);
const status = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"version",
"status",
"--project",
projectRoot,
"--output",
"json",
]);
expect(status.exitCode, status.stderr).toBe(0);
expect(parseStdoutJson<{ enabled?: boolean }>(status.stdout).enabled).toBe(true);
expect(await stat(join(projectRoot, ".openagentpack/state.json")).catch(() => null)).toBeNull();
});
test("managed-agent project version preview 缺少 --version-id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"version",
"preview",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--version-id|Missing required/i);
});
test("managed-agent apply 计划失败时在最终错误中保留首条诊断和剩余数量", async () => {
@@ -1289,6 +1505,29 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () =>
expect(data.provider).toBe("bailian");
});
test("project workbench --dry-run 仅输出目录项目启动计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"workbench",
"--dry-run",
"--project",
"./agent-project",
"--no-open",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
would_launch?: string;
project_root?: string;
port?: number;
}>(stdout);
expect(data.would_launch).toBe("workbench");
expect(data.project_root).toBe("./agent-project");
expect(data.port).toBe(4848);
});
test("init 不再接受 --provider", async () => {
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
@@ -1301,6 +1540,27 @@ describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () =>
expect(stderr).toMatch(/Unknown flag.*--provider/i);
});
test("project init --dry-run 不需要密钥且不创建目录", async () => {
const parent = await mkdtemp(join(tmpdir(), "bailian-managed-agent-project-dry-run-"));
projectDirectories.push(parent);
const projectRoot = join(parent, "project");
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
"project",
"init",
"--dry-run",
"--project",
projectRoot,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
expect(
parseStdoutJson<{ would_initialize_project?: string }>(stdout).would_initialize_project,
).toBe(projectRoot);
expect(await stat(projectRoot).catch(() => null)).toBeNull();
});
test("apply --dry-run 仅输出计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
"managed-agent",
@@ -240,4 +240,75 @@ describe("e2e: pipeline", () => {
expect(stdout).toBe("");
expect(stderr).toMatch(/--events must be one of: jsonl/i);
});
test("pipeline image/generate dry-run 继承 Profile watermark=false", async () => {
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-"));
const workflowPath = join(configDir, "image-generate.json");
try {
await writeFile(
join(configDir, "config.json"),
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
);
await writeFile(
workflowPath,
JSON.stringify({
version: "workflow/v1",
steps: [{ id: "gen", type: "image/generate", input: { prompt: "A cat" } }],
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
);
expect(exitCode, stderr).toBe(0);
const report = parseStdoutJson<{
status?: string;
steps?: Array<{ type?: string; input?: { watermark?: boolean; prompt?: string } }>;
}>(stdout);
expect(report.status).toBe("planned");
expect(report.steps?.[0]).toMatchObject({
type: "image/generate",
input: { prompt: "A cat", watermark: false },
});
} finally {
await rm(configDir, { recursive: true, force: true });
}
});
test("pipeline 步骤显式 watermark=true 覆盖 Profile false", async () => {
const configDir = await mkdtemp(join(tmpdir(), "bl-pipeline-wm-ov-"));
const workflowPath = join(configDir, "image-generate.json");
try {
await writeFile(
join(configDir, "config.json"),
JSON.stringify({ api_key: "sk-test-placeholder", watermark: false }, null, 2) + "\n",
);
await writeFile(
workflowPath,
JSON.stringify({
version: "workflow/v1",
steps: [
{
id: "gen",
type: "image/generate",
input: { prompt: "A cat", watermark: true },
},
],
}),
);
const { stdout, stderr, exitCode } = await runCommandE2e(
PIPELINE_ROUTES,
["pipeline", "run", "--file", workflowPath, "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir, DASHSCOPE_API_KEY: "", DASHSCOPE_BASE_URL: "" },
);
expect(exitCode, stderr).toBe(0);
const report = parseStdoutJson<{
steps?: Array<{ input?: { watermark?: boolean } }>;
}>(stdout);
expect(report.steps?.[0]?.input?.watermark).toBe(true);
} finally {
await rm(configDir, { recursive: true, force: true });
}
});
});
@@ -40,11 +40,18 @@ describe("e2e: speech recognize", () => {
language_hints?: string[];
language?: string;
vocabulary_id?: string;
vocabulary?: Record<string, number>;
};
input?: {
file_url?: string;
file_urls?: string[];
messages?: Array<{ content?: Array<{ type?: string }> }>;
context?: Array<{
role?: string;
content?: Array<{ type?: string; text?: string }>;
}>;
messages?: Array<{
content?: Array<{ type?: string; text?: string; input_audio?: { data?: string } }>;
}>;
};
};
}>(stdout);
@@ -120,6 +127,116 @@ describe("e2e: speech recognize", () => {
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize async dry-run 注入 input.context 与 parameters.vocabulary", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/audio.mp3",
"--vocabulary",
'{"奋斗者":4,"鲸落":4}',
"--context",
"奋斗者号 鲸落 深海勇士",
]);
expect(body.mode).toBe("async");
expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4, 鲸落: 4 });
expect(body.request?.input?.context).toEqual([
{
role: "user",
content: [{ type: "input_text", text: "奋斗者号 鲸落 深海勇士" }],
},
]);
});
test("speech recognize sync input-audio dry-run 将 context 前置且 input_audio 在最后", async () => {
const body = await runRecognizeDryRun([
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
"https://example.com/audio.wav",
"--vocabulary",
'{"奋斗者":4}',
"--context",
"奋斗者号",
]);
expect(body.mode).toBe("sync");
expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4 });
const messages = body.request?.input?.messages ?? [];
expect(messages).toHaveLength(2);
expect(messages[0]?.content?.[0]).toMatchObject({ type: "input_text", text: "奋斗者号" });
expect(messages[1]?.content?.[0]?.type).toBe("input_audio");
});
test("speech recognize 非法 --vocabulary JSON 返回用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--vocabulary",
"{bad json",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|--vocabulary/i);
});
test("speech recognize 空 --vocabulary 返回用法错误", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash-filetrans",
"--url",
"https://example.com/a.wav",
"--vocabulary",
"",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|--vocabulary/i);
});
test("speech recognize qwen3 sync 拒绝 --vocabulary", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen3-asr-flash",
"--url",
"https://example.com/a.wav",
"--vocabulary",
'{"奋斗者":4}',
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--vocabulary|does not support/i);
expect(stderr).toMatch(/qwen-audio-3\.0-asr-flash|vocabulary\/context/i);
});
test("speech recognize sync Flash 拒绝 --diarization 时提示 async filetrans", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"fun-asr-flash",
"--url",
"https://example.com/a.wav",
"--diarization",
"--dry-run",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--diarization|does not support/i);
expect(stderr).toMatch(/async filetrans|fun-asr/i);
expect(stderr).not.toMatch(/vocabulary\/context/i);
});
test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => {
const body = await runRecognizeDryRun([
"--model",
@@ -366,5 +483,88 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
const raw = readFileSync(asrJson, "utf8");
expect(raw.length).toBeGreaterThan(2);
}, 300_000);
test("【qwen-audio】synthesize → recognize 即时热词/上下文", async () => {
// 生造专名:无热词时常被听错;带 --vocabulary/--context 后应能正确召回。
const hotwordScript =
"请把录音同步到听悟匣,并启动澜舟芯做摘要。听悟匣负责转写,澜舟芯负责归档。最后确认玄甲协议是否已开启。";
const hotwords = ["听悟匣", "澜舟芯", "玄甲协议"] as const;
const vocabularyJson = '{"听悟匣":4,"澜舟芯":4,"玄甲协议":4}';
const contextText = "听悟匣 澜舟芯 玄甲协议";
const missingHotwords = (text: string): string[] => {
const normalized = text.replace(/\s+/g, "");
return hotwords.filter((word) => !normalized.includes(word));
};
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
const outMp3 = join(outDir, "hotword-tts.mp3");
const syn = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"synthesize",
"--model",
"cosyvoice-v3-flash",
"--voice",
"longxiaochun_v3",
"--text",
hotwordScript,
"--out",
outMp3,
"--output",
"json",
]);
expect(syn.exitCode, syn.stderr).toBe(0);
const synBody = parseStdoutJson<{ audio_url?: string }>(syn.stdout);
const audioUrl = synBody.audio_url;
expect(audioUrl?.startsWith("http")).toBe(true);
const baselineOut = join(outDir, "asr-baseline.json");
const baseline = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
audioUrl!,
"--language",
"zh",
"--out",
baselineOut,
"--quiet",
]);
expect(baseline.exitCode, baseline.stderr).toBe(0);
writeFileSync(join(outDir, "asr-baseline.txt"), baseline.stdout);
const baselineMissing = missingHotwords(baseline.stdout);
// soft:仅落盘对照,不 fail(无热词偶发也能认出专名)
writeFileSync(
join(outDir, "asr-baseline-missing.txt"),
baselineMissing.length > 0 ? baselineMissing.join("\n") + "\n" : "(none)\n",
);
const hotOut = join(outDir, "asr-hot.json");
const hot = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"recognize",
"--model",
"qwen-audio-3.0-asr-flash",
"--url",
audioUrl!,
"--language",
"zh",
"--vocabulary",
vocabularyJson,
"--context",
contextText,
"--out",
hotOut,
"--quiet",
]);
expect(hot.exitCode, hot.stderr).toBe(0);
writeFileSync(join(outDir, "asr-hot.txt"), hot.stdout);
expect(
missingHotwords(hot.stdout),
`expected hotwords in ASR text, got: ${hot.stdout.trim()}`,
).toEqual([]);
}, 420_000);
},
);
@@ -0,0 +1,551 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vite-plus/test";
import {
e2eLabelFromMetaUrl,
isBailianE2EMediaEnabled,
isDashScopeE2EReady,
makeE2eOutputDir,
parseStdoutJson,
runCommandHelp,
runCommandE2e,
} from "./helpers.ts";
import { SPEECH_ROUTES } from "./topic-routes.ts";
/**
* Speech vocabulary:help / dry-run / 确认闸门无密钥;真实 CRUD 需媒体 E2E + DashScope。
*/
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function makeTempJson(content: string): string {
const tempDir = mkdtempSync(join(tmpdir(), "bl-vocab-e2e-"));
tempDirs.push(tempDir);
const filePath = join(tempDir, "hotwords.json");
writeFileSync(filePath, content);
return filePath;
}
describe("e2e: speech vocabulary", () => {
test("speech vocabulary --help 列出 5 个子命令", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/create/i);
expect(stderr).toMatch(/list/i);
expect(stderr).toMatch(/get/i);
expect(stderr).toMatch(/update/i);
expect(stderr).toMatch(/delete/i);
});
test("create --help 展示关键 flags 与静默失效 / weight 50 文案", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--model/i);
expect(stderr).toMatch(/--prefix/i);
expect(stderr).toMatch(/--words/i);
expect(stderr).toMatch(/--words-file/i);
expect(stderr).toMatch(/silently ignored|静默失效/i);
expect(stderr).toMatch(/50/);
});
test("delete --help 展示 Risk 与 --yes", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/Risk|风险/i);
});
test("update --help 展示 Risk、replace 语义与 --yes", async () => {
const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--yes/i);
expect(stderr).toMatch(/Risk|风险/i);
expect(stderr).toMatch(/replaces|替换/i);
});
test("create 缺少 --model 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--model|Missing required/i);
});
test("create 缺少 --prefix 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--words",
'{"x":4}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--prefix|Missing required/i);
});
test("create 两个词表 flag 都不传时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--words|--words-file/i);
});
test("create 同时传 --words 与 --words-file 时退出为用法错误 (2)", async () => {
const filePath = makeTempJson('{"x":4}');
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--words-file",
filePath,
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/either|--words|--words-file/i);
});
test("create 非法 JSON 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
"{bad json",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/not valid JSON|JSON/i);
});
test("create 字符串权重时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":"4"}',
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/must be a number|number/i);
});
test("create 空词表 {} 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
"{}",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/at least one|hot word/i);
});
test("get 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required/i);
});
test("delete 缺少 --id 时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--quiet",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--id|Missing required/i);
});
test("create object 形态 --dry-run 输出 speech-biasing 信封", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"奋斗者":4,"鲸落":4}',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
model?: string;
input?: {
action?: string;
target_model?: string;
prefix?: string;
vocabulary?: Array<{ text?: string; weight?: number; lang?: string }>;
};
};
}>(stdout);
expect(body.request?.model).toBe("speech-biasing");
expect(body.request?.input?.action).toBe("create_vocabulary");
expect(body.request?.input?.target_model).toBe("fun-asr");
expect(body.request?.input?.prefix).toBe("demo");
expect(body.request?.input?.vocabulary).toEqual([
{ text: "奋斗者", weight: 4 },
{ text: "鲸落", weight: 4 },
]);
});
test("create array 形态 --dry-run 原样透传 lang", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'[{"text":"x","weight":4,"lang":"zh"}]',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]);
});
test("create object + --lang --dry-run 下发到每一条", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words",
'{"x":4}',
"--lang",
"zh",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]);
});
test("create --words-file --dry-run 读取文件", async () => {
const filePath = makeTempJson('{"from-file":4}');
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"demo",
"--words-file",
filePath,
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: { vocabulary?: Array<{ text?: string; weight?: number }> };
};
}>(stdout);
expect(body.request?.input?.vocabulary).toEqual([{ text: "from-file", weight: 4 }]);
});
test("list --page 2 --dry-run 将 page_index 转为 1", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"list",
"--page",
"2",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; page_index?: number } };
}>(stdout);
expect(body.request?.input?.action).toBe("list_vocabulary");
expect(body.request?.input?.page_index).toBe(1);
});
test("get --dry-run 使用 query_vocabulary", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--id",
"vocab-x",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; vocabulary_id?: string } };
}>(stdout);
expect(body.request?.input?.action).toBe("query_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
});
test("update --dry-run 无 --yes 也能预览", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--id",
"vocab-x",
"--words",
'{"x":4}',
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: {
input?: {
action?: string;
vocabulary_id?: string;
vocabulary?: Array<{ text?: string; weight?: number }>;
};
};
}>(stdout);
expect(body.request?.input?.action).toBe("update_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4 }]);
});
test("delete --dry-run 无 --yes 也能预览", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
"vocab-x",
"--dry-run",
"--output",
"json",
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const body = parseStdoutJson<{
request?: { input?: { action?: string; vocabulary_id?: string } };
}>(stdout);
expect(body.request?.input?.action).toBe("delete_vocabulary");
expect(body.request?.input?.vocabulary_id).toBe("vocab-x");
});
});
describe("e2e: speech vocabulary high-risk confirmation", () => {
test("delete 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
"vocab-x",
"--api-key",
"e2e-dummy-key",
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("update 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"update",
"--id",
"vocab-x",
"--words",
'{"x":4}',
"--api-key",
"e2e-dummy-key",
"--output",
"json",
]);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
});
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
"e2e: speech vocabulary(DashScope 媒体)",
() => {
test("create → list/get → delete 完整链路", async () => {
const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url));
let vocabularyId = "";
try {
const created = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"create",
"--model",
"fun-asr",
"--prefix",
"blcli",
"--words",
'{"奋斗者":4}',
"--quiet",
]);
expect(created.exitCode, created.stderr).toBe(0);
vocabularyId = created.stdout.trim();
expect(vocabularyId.length).toBeGreaterThan(0);
writeFileSync(join(outDir, "vocabulary-id.txt"), vocabularyId + "\n");
const listed = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"list",
"--prefix",
"blcli",
"--output",
"json",
]);
expect(listed.exitCode, listed.stderr).toBe(0);
const listBody = parseStdoutJson<{
output?: { vocabulary_list?: Array<{ vocabulary_id?: string; status?: string }> };
}>(listed.stdout);
const listedItem = listBody.output?.vocabulary_list?.find(
(item) => item.vocabulary_id === vocabularyId,
);
expect(listedItem).toBeTruthy();
expect(listedItem?.status).toBe("OK");
const got = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"get",
"--id",
vocabularyId,
"--output",
"json",
]);
expect(got.exitCode, got.stderr).toBe(0);
const getBody = parseStdoutJson<{
output?: { status?: string; target_model?: string };
}>(got.stdout);
expect(getBody.output?.status).toBe("OK");
expect(getBody.output?.target_model).toBe("fun-asr");
} finally {
if (vocabularyId) {
const deleted = await runCommandE2e(SPEECH_ROUTES, [
"speech",
"vocabulary",
"delete",
"--id",
vocabularyId,
"--yes",
"--quiet",
]);
expect(deleted.exitCode, deleted.stderr).toBe(0);
}
}
}, 120_000);
},
);
@@ -72,6 +72,11 @@ export const VISION_ROUTES: E2eRouteExports = {
export const SPEECH_ROUTES: E2eRouteExports = {
"speech synthesize": "speechSynthesize",
"speech recognize": "speechRecognize",
"speech vocabulary create": "speechVocabularyCreate",
"speech vocabulary list": "speechVocabularyList",
"speech vocabulary get": "speechVocabularyGet",
"speech vocabulary update": "speechVocabularyUpdate",
"speech vocabulary delete": "speechVocabularyDelete",
};
export const MCP_ROUTES: E2eRouteExports = {
@@ -188,6 +193,18 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
"managed-agent plan": "managedAgentPlan",
"managed-agent apply": "managedAgentApply",
"managed-agent destroy": "managedAgentDestroy",
"managed-agent playground": "managedAgentPlayground",
"managed-agent project init": "managedAgentProjectInit",
"managed-agent project validate": "managedAgentProjectValidate",
"managed-agent project build": "managedAgentProjectBuild",
"managed-agent project publish": "managedAgentProjectPublish",
"managed-agent project workbench": "managedAgentProjectWorkbench",
"managed-agent project version enable": "managedAgentProjectVersionEnable",
"managed-agent project version disable": "managedAgentProjectVersionDisable",
"managed-agent project version status": "managedAgentProjectVersionStatus",
"managed-agent project version list": "managedAgentProjectVersionList",
"managed-agent project version preview": "managedAgentProjectVersionPreview",
"managed-agent project version restore": "managedAgentProjectVersionRestore",
"managed-agent state list": "managedAgentStateList",
"managed-agent state rm": "managedAgentStateRm",
"managed-agent state import": "managedAgentStateImport",
@@ -0,0 +1,80 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { IMAGE_ROUTES, VIDEO_ROUTES, type E2eRouteExports } from "./topic-routes.ts";
interface WatermarkScenario {
name: string;
routes: E2eRouteExports;
args: string[];
}
const scenarios: WatermarkScenario[] = [
{
name: "image generate",
routes: IMAGE_ROUTES,
args: ["image", "generate", "--prompt", "A cat"],
},
{
name: "image edit",
routes: IMAGE_ROUTES,
args: [
"image",
"edit",
"--image",
"https://example.com/input.png",
"--prompt",
"Blue background",
],
},
{
name: "video generate",
routes: VIDEO_ROUTES,
args: ["video", "generate", "--prompt", "A cat waves"],
},
{
name: "video edit",
routes: VIDEO_ROUTES,
args: ["video", "edit", "--video", "https://example.com/input.mp4", "--prompt", "Warm colors"],
},
{
name: "video ref",
routes: VIDEO_ROUTES,
args: [
"video",
"ref",
"--image",
"https://example.com/person.png",
"--prompt",
"Image 1 waves",
],
},
];
describe("e2e: global watermark config", () => {
for (const scenario of scenarios) {
test(`${scenario.name} uses watermark=false from the selected Profile`, async () => {
const configDir = mkdtempSync(join(tmpdir(), "bl-watermark-profile-"));
try {
writeFileSync(
join(configDir, "config.json"),
JSON.stringify({ media: { watermark: false } }, null, 2) + "\n",
);
const { stdout, stderr, exitCode } = await runCommandE2e(
scenario.routes,
[...scenario.args, "--config", "media", "--dry-run", "--output", "json"],
{ BAILIAN_CONFIG_DIR: configDir },
);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
request?: { parameters?: { watermark?: boolean } };
}>(stdout);
expect(data.request?.parameters?.watermark).toBe(false);
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});
}
});
@@ -0,0 +1,139 @@
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
commitPreparedProjectVersion,
disableProjectVersioning,
enableProjectVersioning,
getProjectVersionStatus,
listProjectVersions,
prepareProjectVersion,
previewProjectVersion,
restoreProjectVersion,
} from "@openagentpack/sdk/project-versions";
import { afterEach, describe, expect, test } from "vite-plus/test";
import { playgroundBrowserTargetFromSummary } from "../src/commands/managed-agent/_engine/playground-launcher.ts";
const temporaryDirectories: string[] = [];
afterEach(async () => {
for (const directory of temporaryDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true });
}
});
describe("managed-agent local snapshot versions", () => {
test("uses the shared path-scoped switch and stores full YAML outside store.json", async () => {
const root = await temporaryDirectory();
const configPath = join(root, "agents.yaml");
const siblingDirectory = join(root, "nested");
const siblingConfigPath = join(siblingDirectory, "agents.yaml");
await mkdir(siblingDirectory);
await writeFile(configPath, projectYaml("First"));
await writeFile(siblingConfigPath, projectYaml("Second"));
const enabled = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
expect(enabled.versioning.enabled).toBe(true);
expect(enabled.version?.message).toBe("Enable Bailian CLI versioning");
expect((await getProjectVersionStatus(siblingConfigPath)).enabled).toBe(false);
const storeSource = await readFile(join(root, ".openagentpack/versions/store.json"), "utf8");
expect(storeSource).not.toContain("instructions: First");
const snapshotSource = await readFile(
join(root, ".openagentpack/versions/blobs", `${enabled.version!.source_hash}.yaml`),
"utf8",
);
expect(snapshotSource).toContain("instructions: First");
await writeFile(configPath, projectYaml("First updated"));
const repeated = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
expect(repeated.version?.message).toBe("Enable Bailian CLI versioning");
expect((await listProjectVersions(configPath)).versions).toHaveLength(2);
const disabled = await disableProjectVersioning(configPath);
expect(disabled.enabled).toBe(false);
});
test("auto-snapshots after success and restores without changing history or permissions", async () => {
const root = await temporaryDirectory();
const configPath = join(root, "agents.yaml");
await writeFile(configPath, projectYaml("Version one"));
await chmod(configPath, 0o640);
const enabled = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
const firstVersion = enabled.version!.version_id;
const secondSource = projectYaml("Version two");
await writeFile(configPath, secondSource);
const prepared = await prepareProjectVersion(configPath, secondSource);
const version = await commitPreparedProjectVersion(prepared!);
const currentVersionBeforeRestore = (await getProjectVersionStatus(configPath)).head_version;
expect(version?.message).toBe("Apply agents.yaml");
const preview = await previewProjectVersion(configPath, firstVersion);
expect(preview.can_restore).toBe(true);
expect(preview.after_yaml).toContain("Version one");
await restoreProjectVersion(configPath, firstVersion, {
headVersion: preview.base_head_version,
sourceRevision: preview.base_source_revision,
});
expect(await readFile(configPath, "utf8")).toContain("Version one");
expect((await getProjectVersionStatus(configPath)).head_version).toBe(
currentVersionBeforeRestore,
);
expect((await stat(configPath)).mode & 0o777).toBe(0o640);
});
test("rejects abbreviated version IDs and plaintext credentials", async () => {
const root = await temporaryDirectory();
const configPath = join(root, "agents.yaml");
await writeFile(configPath, projectYaml("Safe"));
const enabled = await enableProjectVersioning(configPath, "Enable Bailian CLI versioning");
await expect(previewProjectVersion(configPath, enabled.version!.short_version)).rejects.toThrow(
/full 64-character hexadecimal/i,
);
await disableProjectVersioning(configPath);
await writeFile(
configPath,
projectYaml("Unsafe").replace("qoder: {}", "qoder:\n api_key: plaintext-secret"),
);
await expect(
enableProjectVersioning(configPath, "Enable Bailian CLI versioning"),
).rejects.toThrow(/environment variable reference/i);
});
});
describe("managed-agent Workbench policy", () => {
test("Session Preview opens the requested Agent or falls back to Workbench", () => {
const summary = {
status: "valid",
agents: [{ agent: { id: "assistant" } }, { agent: { id: "reviewer" } }],
};
expect(
playgroundBrowserTargetFromSummary("http://localhost:4848", summary, "reviewer"),
).toEqual({ url: "http://localhost:4848/agents/reviewer/preview" });
expect(playgroundBrowserTargetFromSummary("http://localhost:4848", summary)).toEqual(
expect.objectContaining({ url: "http://localhost:4848", warning: expect.any(String) }),
);
});
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "bailian-cli-local-versions-"));
temporaryDirectories.push(directory);
return directory;
}
function projectYaml(instructions: string): string {
return `version: "1"
providers:
qoder: {}
defaults:
provider: qoder
agents:
assistant:
model: ultimate
instructions: ${instructions}
`;
}
@@ -0,0 +1,137 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
const registry = vi.hoisted(() => ({
version: '"0.7.0"',
error: null as Error | null,
manifest: "",
query: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
execFile: (
command: string,
args: string[],
options: unknown,
callback: (error: Error | null, output: { stdout: string }) => void,
) => {
registry.query(command, args, options);
callback(registry.error, { stdout: registry.version });
},
}));
vi.mock("node:module", () => ({
createRequire: () => ({ resolve: () => registry.manifest }),
}));
import { resolveLauncher } from "../src/commands/managed-agent/_engine/playground-launcher.ts";
let directory: string;
let binary: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), "playground-launcher-"));
binary = join(directory, "playground.js");
registry.manifest = join(directory, "package.json");
registry.version = '"0.7.0"';
registry.error = null;
registry.query.mockClear();
await writeFile(binary, "");
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", "");
vi.stubEnv("AGENTS_PLAYGROUND_BIN", "");
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "");
vi.spyOn(process, "cwd").mockReturnValue(directory);
});
afterEach(async () => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
async function install(version: string) {
await writeFile(registry.manifest, JSON.stringify({ version, bin: "playground.js" }));
}
test("checks latest on every resolution and reuses only the matching installed version", async () => {
await install("0.7.0");
expect(await resolveLauncher()).toMatchObject({
args: [binary],
version: "0.7.0",
fetched: false,
});
registry.version = '"0.8.0"';
expect(await resolveLauncher()).toMatchObject({
command: "npx",
args: ["-y", "@openagentpack/playground@0.8.0"],
version: "0.8.0",
fetched: true,
});
expect(registry.query).toHaveBeenCalledTimes(2);
expect(registry.query).toHaveBeenCalledWith(
"npm",
expect.arrayContaining([
"view",
"@openagentpack/playground@latest",
"--prefer-online",
"--fetch-timeout=10000",
]),
expect.objectContaining({ timeout: 15_000 }),
);
});
test("downloads the resolved exact version when local installation is old or missing", async () => {
for (const version of ["0.6.0", undefined]) {
if (version) await install(version);
else await rm(registry.manifest);
expect(await resolveLauncher()).toMatchObject({
command: "npx",
args: ["-y", "@openagentpack/playground@0.7.0"],
});
}
});
test("explicit version overrides an incompatible installed version", async () => {
await install("0.6.0");
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_VERSION", "0.7.0");
expect(await resolveLauncher()).toMatchObject({ fetched: true, version: "0.7.0" });
expect(registry.query.mock.calls[0]?.[1]).toContain("@openagentpack/playground@0.7.0");
});
test("explicit binary remains an offline development override", async () => {
vi.stubEnv("BAILIAN_MANAGED_AGENT_PLAYGROUND_BIN", binary);
expect(await resolveLauncher()).toMatchObject({ args: [binary], fetched: false });
expect(registry.query).not.toHaveBeenCalled();
});
test("registry failure does not silently reuse an old installation", async () => {
await install("0.6.0");
registry.error = new Error("registry unavailable");
await expect(resolveLauncher()).rejects.toThrow("registry unavailable");
});
test("rejects malformed or ambiguous registry metadata with localized diagnostics", async () => {
for (const output of ["invalid", '["0.6.0","0.7.0"]', '"--unsafe"']) {
registry.version = output;
await expect(resolveLauncher()).rejects.toThrow("single valid Playground version");
await expect(resolveLauncher()).rejects.toThrow("唯一有效的 Playground 版本号");
}
});
test("local source builds must also match the resolved version", async () => {
const packageRoot = join(directory, "packages/playground");
const sourceBinary = join(packageRoot, "dist/bin/playground.js");
await mkdir(join(packageRoot, "dist/bin"), { recursive: true });
await writeFile(sourceBinary, "");
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.6.0" }));
expect(await resolveLauncher()).toMatchObject({ fetched: true });
await writeFile(join(packageRoot, "package.json"), JSON.stringify({ version: "0.7.0" }));
expect(await resolveLauncher()).toMatchObject({
args: [sourceBinary],
version: "0.7.0",
fetched: false,
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.20.0",
"version": "1.23.0",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+38 -18
View File
@@ -1,16 +1,20 @@
import { imageSyncPath, speechRecognizePath } from "./endpoints.ts";
import type { AsrContextMessage } from "../types/api.ts";
/**
* DashScope ASR APIs differ by model family:
*
* - async file transcription (`.../audio/asr/transcription`):
* fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice*
* language via `parameters.language_hints`
* language via `parameters.language_hints`; optional `input.context` /
* `parameters.vocabulary` (model-dependent effective range)
* - sync multimodal (`.../aigc/multimodal-generation/generation`):
* - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language`
* (qwen3-asr-flash*)
* (qwen3-asr-flash*) — no vocabulary / context fields in this body shape
* - input-audio: `{ type: input_audio, input_audio.data }` +
* `format`/`sample_rate` + optional `language_hints`
* `format`/`sample_rate` + optional `language_hints` /
* `vocabulary_id` / `vocabulary`; optional leading `input_text` for context
* (fun-asr-flash*, qwen-audio-*-asr-flash*)
* - realtime / streaming: WebSocket — not supported by `speech recognize`
*/
@@ -158,9 +162,18 @@ export interface BuildAsrFlashRequestOpts {
language?: string;
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
vocabularyId?: string;
/** Instant hot words (word → weight); input-audio Flash only (command layer rejects qwen3). */
vocabulary?: Record<string, number>;
/** Context enhancement text; prepended as input_text before input_audio. */
context?: string;
flashFamily: AsrFlashFamily;
}
/** Wrap plain text as a single user context message for ASR. */
export function buildAsrContextMessages(text: string): AsrContextMessage[] {
return [{ role: "user", content: [{ type: "input_text", text }] }];
}
/**
* Build language fields for async ASR routes.
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
@@ -178,10 +191,10 @@ export function buildAsyncAsrLanguageFields(
/** Build a sync multimodal ASR request body for Flash models. */
export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<string, unknown> {
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
const { model, audioUrl, language, vocabularyId, vocabulary, context, flashFamily } = opts;
if (flashFamily === "input-audio") {
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary(_id)
const parameters: Record<string, unknown> = {
format: inferAudioFormatHint(audioUrl),
sample_rate: "16000",
@@ -192,21 +205,28 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<str
if (vocabularyId) {
parameters.vocabulary_id = vocabularyId;
}
if (vocabulary) {
parameters.vocabulary = vocabulary;
}
// input_audio must be the last message; prepend context as input_text when present
const messages: Array<Record<string, unknown>> = [];
if (context) {
for (const message of buildAsrContextMessages(context)) {
messages.push(message as unknown as Record<string, unknown>);
}
}
messages.push({
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
});
return {
model,
input: {
messages: [
{
role: "user",
content: [
{
type: "input_audio",
input_audio: { data: audioUrl },
},
],
},
],
},
input: { messages },
parameters,
};
}
+5
View File
@@ -89,6 +89,11 @@ export function speechRecognizePath(): string {
return "/api/v1/services/audio/asr/transcription";
}
// ---- Hot-word Vocabulary (ASR customization) ----
export function speechVocabularyPath(): string {
return "/api/v1/services/audio/asr/customization";
}
// ---- Memory Profile (DashScope v2) ----
export function profileSchemaPath(): string {
return "/api/v2/apps/memory/profile_schemas";
+2
View File
@@ -21,6 +21,7 @@ export {
responsesPath,
speechRecognizePath,
speechSynthesizePath,
speechVocabularyPath,
taskPath,
userProfilePath,
videoGeneratePath,
@@ -41,6 +42,7 @@ export {
type ImageSizeProfile,
} from "./image-routes.ts";
export {
buildAsrContextMessages,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
+1
View File
@@ -272,6 +272,7 @@ export function buildSettings(s: ResolutionSources): Settings {
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
outputDir: file.output_dir || undefined,
timeout,
watermark: file.watermark ?? true,
defaultTextModel: file.default_text_model,
defaultVideoModel: file.default_video_model,
defaultImageToVideoModel: file.default_image_to_video_model,
+4
View File
@@ -35,6 +35,7 @@ export interface ConfigFile {
output?: "text" | "json";
output_dir?: string;
timeout?: number;
watermark?: boolean;
default_text_model?: string;
default_video_model?: string;
default_image_to_video_model?: string;
@@ -63,6 +64,7 @@ export const CONFIG_FILE_KEYS = [
"output",
"output_dir",
"timeout",
"watermark",
"default_text_model",
"default_video_model",
"default_image_to_video_model",
@@ -156,6 +158,7 @@ export function parseConfigFile(raw: unknown): ConfigFile {
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
out.output_dir = obj.output_dir;
if (typeof obj.timeout === "number" && obj.timeout > 0) out.timeout = obj.timeout;
if (typeof obj.watermark === "boolean") out.watermark = obj.watermark;
if (typeof obj.default_text_model === "string" && obj.default_text_model.length > 0)
out.default_text_model = obj.default_text_model;
if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0)
@@ -224,6 +227,7 @@ export interface Settings {
outputExplicit: boolean;
outputDir?: string;
timeout: number;
watermark: boolean;
defaultTextModel?: string;
defaultVideoModel?: string;
defaultImageToVideoModel?: string;
+1
View File
@@ -13,6 +13,7 @@ export * from "./files/index.ts";
export * from "./dataset/index.ts";
export * from "./finetune/index.ts";
export * from "./deploy/index.ts";
export * from "./speech/index.ts";
export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./telemetry/index.ts";
+14
View File
@@ -0,0 +1,14 @@
export {
SPEECH_BIASING_MODEL,
buildVocabularyRequest,
createVocabulary,
listVocabularies,
queryVocabulary,
updateVocabulary,
deleteVocabulary,
type VocabularyEntry,
type VocabularyListItem,
type VocabularyEnvelope,
type VocabularyRequest,
} from "./vocabulary.ts";
export { parseInstantVocabulary, parseVocabularyEntries } from "./vocabulary-input.ts";
@@ -0,0 +1,76 @@
import { UsageError } from "../errors/base.ts";
import type { VocabularyEntry } from "./vocabulary.ts";
/** Shared JSON decode + top-level shape guard for both hot-word flags. */
function decodeVocabularyJson(raw: string, flagName: string): unknown {
try {
return JSON.parse(raw);
} catch (error) {
throw new UsageError(`${flagName} is not valid JSON — ${(error as Error).message}`);
}
}
/**
* Instant hot words for `recognize --vocabulary`.
* The API field is a flat word→weight object, so an array is a usage error here.
*/
export function parseInstantVocabulary(raw: string): Record<string, number> {
const parsed = decodeVocabularyJson(raw, "--vocabulary");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new UsageError("--vocabulary must decode to a JSON object of word→weight.");
}
for (const [word, weight] of Object.entries(parsed)) {
if (typeof weight !== "number" || !Number.isFinite(weight)) {
throw new UsageError(`--vocabulary weight for "${word}" must be a number.`);
}
}
return parsed as Record<string, number>;
}
/**
* Vocabulary create/update --words.
* Accepts the same word→weight object as recognize, or the API entry array
* when per-entry lang is needed. Array form ignores the optional lang param.
*/
export function parseVocabularyEntries(raw: string, lang?: string): VocabularyEntry[] {
const flagName = "--words";
const parsed = decodeVocabularyJson(raw, flagName);
let entries: VocabularyEntry[];
if (Array.isArray(parsed)) {
entries = parsed.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new UsageError(
`${flagName} entry #${index} must be an object with string "text" and number "weight".`,
);
}
const entry = item as Partial<VocabularyEntry>;
if (typeof entry.text !== "string" || typeof entry.weight !== "number") {
throw new UsageError(
`${flagName} entry #${index} must have a string "text" and a number "weight".`,
);
}
if (!Number.isFinite(entry.weight)) {
throw new UsageError(`${flagName} entry #${index} weight must be a finite number.`);
}
return {
text: entry.text,
weight: entry.weight,
...(typeof entry.lang === "string" ? { lang: entry.lang } : {}),
};
});
} else if (!parsed || typeof parsed !== "object") {
throw new UsageError(`${flagName} must decode to a JSON object or array.`);
} else {
entries = [];
for (const [text, weight] of Object.entries(parsed)) {
if (typeof weight !== "number" || !Number.isFinite(weight)) {
throw new UsageError(`${flagName} weight for "${text}" must be a number.`);
}
entries.push({ text, weight, ...(lang ? { lang } : {}) });
}
}
if (entries.length === 0) {
throw new UsageError(`${flagName} must contain at least one hot word.`);
}
return entries;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Hot-word vocabulary HTTP API wrappers.
*
* Thin functions over `requestJson`. They return the parsed body verbatim
* (snake_case) so callers can decide how to surface fields.
*/
import { speechVocabularyPath } from "../client/endpoints.ts";
import type { Client } from "../client/client.ts";
/** Fixed model id for the hot-word customization endpoint. */
export const SPEECH_BIASING_MODEL = "speech-biasing";
export interface VocabularyEntry {
text: string;
weight: number;
lang?: string;
}
export interface VocabularyListItem {
vocabulary_id?: string;
gmt_create?: string;
gmt_modified?: string;
/** OK | UNDEPLOYED — UNDEPLOYED vocabularies are silently ignored by ASR. */
status?: string;
}
export interface VocabularyEnvelope<T> {
request_id?: string;
output?: T;
usage?: { count?: number };
}
export interface VocabularyRequest {
model: string;
input: Record<string, unknown>;
}
/** Shared request body builder for dry-run and live calls. */
export function buildVocabularyRequest(
action: string,
input: Record<string, unknown>,
): VocabularyRequest {
return {
model: SPEECH_BIASING_MODEL,
input: { action, ...input },
};
}
async function callVocabularyApi<T>(
client: Client,
action: string,
input: Record<string, unknown>,
signal?: AbortSignal,
): Promise<VocabularyEnvelope<T>> {
return client.requestJson<VocabularyEnvelope<T>>({
path: speechVocabularyPath(),
method: "POST",
body: buildVocabularyRequest(action, input),
signal,
});
}
export function createVocabulary(
client: Client,
params: { targetModel: string; prefix: string; vocabulary: VocabularyEntry[] },
signal?: AbortSignal,
): Promise<VocabularyEnvelope<{ vocabulary_id?: string }>> {
return callVocabularyApi(
client,
"create_vocabulary",
{
target_model: params.targetModel,
prefix: params.prefix,
vocabulary: params.vocabulary,
},
signal,
);
}
export function listVocabularies(
client: Client,
params: { prefix?: string; pageIndex?: number; pageSize?: number } = {},
signal?: AbortSignal,
): Promise<VocabularyEnvelope<{ vocabulary_list?: VocabularyListItem[] }>> {
const input: Record<string, unknown> = {};
if (params.prefix !== undefined) input.prefix = params.prefix;
if (params.pageIndex !== undefined) input.page_index = params.pageIndex;
if (params.pageSize !== undefined) input.page_size = params.pageSize;
return callVocabularyApi(client, "list_vocabulary", input, signal);
}
export function queryVocabulary(
client: Client,
vocabularyId: string,
signal?: AbortSignal,
): Promise<
VocabularyEnvelope<{
gmt_create?: string;
gmt_modified?: string;
status?: string;
target_model?: string;
vocabulary?: VocabularyEntry[];
}>
> {
return callVocabularyApi(client, "query_vocabulary", { vocabulary_id: vocabularyId }, signal);
}
export function updateVocabulary(
client: Client,
vocabularyId: string,
vocabulary: VocabularyEntry[],
signal?: AbortSignal,
): Promise<VocabularyEnvelope<Record<string, never>>> {
return callVocabularyApi(
client,
"update_vocabulary",
{ vocabulary_id: vocabularyId, vocabulary },
signal,
);
}
export function deleteVocabulary(
client: Client,
vocabularyId: string,
signal?: AbortSignal,
): Promise<VocabularyEnvelope<Record<string, never>>> {
return callVocabularyApi(client, "delete_vocabulary", { vocabulary_id: vocabularyId }, signal);
}
+10
View File
@@ -580,11 +580,19 @@ export interface DashScopeTTSStreamChunk {
// ---- Speech Recognition / ASR (DashScope) ----
/** Context-enhancement message for async ASR `input.context` / sync Flash `input.messages`. */
export interface AsrContextMessage {
role: "user" | "assistant";
content: Array<{ type: "input_text" | "text"; text: string }>;
}
export interface DashScopeASRRequest {
model: string;
input: {
file_urls?: string[];
file_url?: string;
/** Context enhancement for async filetrans (array of chat-style messages). */
context?: AsrContextMessage[];
};
parameters?: {
channel_id?: number[];
@@ -595,6 +603,8 @@ export interface DashScopeASRRequest {
diarization_enabled?: boolean;
speaker_count?: number;
vocabulary_id?: string;
/** Instant hot words (word → weight); takes effect on Qwen-Audio-3.0-ASR-Flash series. */
vocabulary?: Record<string, number>;
};
}
+3 -1
View File
@@ -287,8 +287,10 @@ export interface Command<F extends FlagsDef = FlagsDef> {
* Cross-flag validation, after parsing and before run. Return an error message
* → UsageError; undefined to pass. Single-flag `required` is enforced by the
* parser — use this for rules spanning flags or depending on a flag's *value*.
* May be async for read-only local preflight. Runtime awaits it before auth
* and confirmation. Do not perform remote requests or local writes here.
*/
validate?: (flags: ParsedFlags<F>) => string | undefined;
validate?: (flags: ParsedFlags<F>) => string | undefined | Promise<string | undefined>;
run: (ctx: CommandContext<F>) => Promise<void>;
}
+1
View File
@@ -49,6 +49,7 @@ export type {
ChatRequest,
ChatResponse,
ChatTool,
AsrContextMessage,
DashScopeASRRequest,
DashScopeASRTaskResult,
DashScopeASRTranscriptionItem,
+3 -3
View File
@@ -34,7 +34,7 @@ export function resolveBooleanFlag(
return defaultWhenUnset;
}
/** Resolve `--watermark` flag; default true when unset. */
export function resolveWatermark(flagValue: unknown): boolean {
return parseOptionalBooleanValue(flagValue, "watermark") ?? true;
/** Resolve `--watermark`; command flag overrides config, then defaults to true. */
export function resolveWatermark(flagValue: unknown, configuredValue?: boolean): boolean {
return parseOptionalBooleanValue(flagValue, "watermark") ?? configuredValue ?? true;
}
+42
View File
@@ -1,5 +1,6 @@
import { expect, test } from "vite-plus/test";
import {
buildAsrContextMessages,
buildAsrFlashRequest,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
@@ -141,6 +142,47 @@ test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
});
});
test("buildAsrContextMessages wraps plain text as a single user input_text message", () => {
expect(buildAsrContextMessages("奋斗者号 鲸落")).toEqual([
{ role: "user", content: [{ type: "input_text", text: "奋斗者号 鲸落" }] },
]);
});
test("buildAsrFlashRequest injects instant vocabulary and prepends context before input_audio", () => {
const body = buildAsrFlashRequest({
model: "qwen-audio-3.0-asr-flash",
audioUrl: "https://example.com/a.wav",
vocabulary: { 奋斗者: 4, 鲸落: 4 },
context: "奋斗者号 鲸落",
flashFamily: "input-audio",
});
expect(body).toEqual({
model: "qwen-audio-3.0-asr-flash",
input: {
messages: [
{
role: "user",
content: [{ type: "input_text", text: "奋斗者号 鲸落" }],
},
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
parameters: {
format: "wav",
sample_rate: "16000",
vocabulary: { 奋斗者: 4, 鲸落: 4 },
},
});
const messages = (body.input as { messages: Array<{ content: Array<{ type?: string }> }> })
.messages;
expect(messages[messages.length - 1]?.content?.[0]?.type).toBe("input_audio");
});
test("buildAsyncAsrLanguageFields maps language by async style", () => {
expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({
language_hints: ["zh"],
@@ -79,6 +79,14 @@ test("default_speech_recognition_model 从配置文件进入运行时 Settings",
expect(resolve({ file }).defaultSpeechRecognitionModel).toBe("qwen-audio-3.0-asr-flash");
});
test("watermark 从配置文件进入 Settings,缺省时保持合规默认值 true", () => {
expect(parseConfigFile({ watermark: false }).watermark).toBe(false);
expect(parseConfigFile({ watermark: true }).watermark).toBe(true);
expect(parseConfigFile({ watermark: "false" }).watermark).toBeUndefined();
expect(resolve({ file: { watermark: false } }).watermark).toBe(false);
expect(resolve({}).watermark).toBe(true);
});
test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => {
const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" };
const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" };
+1
View File
@@ -28,6 +28,7 @@ function makeSettings(configName?: string): Settings {
output: "json",
outputExplicit: false,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
+4
View File
@@ -31,6 +31,7 @@ function testDeps(identity: Partial<Identity> = {}): {
output: "json",
outputExplicit: true,
timeout: 30,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -290,6 +291,9 @@ test("resolveWatermark uses flag or defaults to true", () => {
expect(resolveWatermark("false")).toBe(false);
expect(resolveWatermark("true")).toBe(true);
expect(resolveWatermark(undefined)).toBe(true);
expect(resolveWatermark(undefined, false)).toBe(false);
expect(resolveWatermark("true", false)).toBe(true);
expect(resolveWatermark("false", true)).toBe(false);
});
test("resolveBooleanFlag uses flag or defaultWhenUnset", () => {
+1
View File
@@ -23,6 +23,7 @@ function testDeps(overrides?: Partial<Settings>): { identity: Identity; settings
output: "json",
outputExplicit: true,
timeout: 5,
watermark: true,
verbose: false,
quiet: true,
dryRun: false,
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vite-plus/test";
import {
parseInstantVocabulary,
parseVocabularyEntries,
buildVocabularyRequest,
SPEECH_BIASING_MODEL,
UsageError,
} from "../src/index.ts";
describe("parseInstantVocabulary", () => {
test("接受 word→weight 对象", () => {
expect(parseInstantVocabulary('{"奋斗者":4,"鲸落":5}')).toEqual({
奋斗者: 4,
鲸落: 5,
});
});
test("拒绝数组", () => {
expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(UsageError);
expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(/object/i);
});
test("拒绝字符串权重", () => {
expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(/must be a number/);
});
test("拒绝非有限数字权重(null)", () => {
expect(() => parseInstantVocabulary('{"x":null}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":null}')).toThrow(/must be a number/);
});
test("拒绝 NaN / Infinity 字面量(非法 JSON)", () => {
expect(() => parseInstantVocabulary('{"x":NaN}')).toThrow(UsageError);
expect(() => parseInstantVocabulary('{"x":Infinity}')).toThrow(UsageError);
});
test("拒绝顶层 null", () => {
expect(() => parseInstantVocabulary("null")).toThrow(UsageError);
});
test("非法 JSON 抛 UsageError", () => {
expect(() => parseInstantVocabulary("{bad json")).toThrow(UsageError);
expect(() => parseInstantVocabulary("{bad json")).toThrow(/not valid JSON/);
});
});
describe("parseVocabularyEntries", () => {
test("对象形态转为条目数组", () => {
expect(parseVocabularyEntries('{"奋斗者":4,"鲸落":5}')).toEqual([
{ text: "奋斗者", weight: 4 },
{ text: "鲸落", weight: 5 },
]);
});
test("对象形态叠加 --lang", () => {
expect(parseVocabularyEntries('{"奋斗者":4}', "zh")).toEqual([
{ text: "奋斗者", weight: 4, lang: "zh" },
]);
});
test("数组形态透传 lang", () => {
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"zh"}]')).toEqual([
{ text: "奋斗者", weight: 4, lang: "zh" },
]);
});
test("数组形态忽略第二参 lang", () => {
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4}]', "zh")).toEqual([
{ text: "奋斗者", weight: 4 },
]);
expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"en"}]', "zh")).toEqual([
{ text: "奋斗者", weight: 4, lang: "en" },
]);
});
test("数组缺 text 或 weight 拒绝", () => {
expect(() => parseVocabularyEntries('[{"weight":4}]')).toThrow(UsageError);
expect(() => parseVocabularyEntries('[{"text":"x"}]')).toThrow(UsageError);
});
test("非 object 数组元素拒绝", () => {
expect(() => parseVocabularyEntries('["x"]')).toThrow(UsageError);
expect(() => parseVocabularyEntries("[null]")).toThrow(UsageError);
});
test("空对象 / 空数组拒绝", () => {
expect(() => parseVocabularyEntries("{}")).toThrow(UsageError);
expect(() => parseVocabularyEntries("{}")).toThrow(/at least one/);
expect(() => parseVocabularyEntries("[]")).toThrow(UsageError);
expect(() => parseVocabularyEntries("[]")).toThrow(/at least one/);
});
});
describe("buildVocabularyRequest", () => {
test("固定 model 为 speech-biasing", () => {
const body = buildVocabularyRequest("create_vocabulary", {
target_model: "fun-asr",
prefix: "demo",
vocabulary: [{ text: "奋斗者", weight: 4 }],
});
expect(body.model).toBe(SPEECH_BIASING_MODEL);
expect(body.model).toBe("speech-biasing");
expect(body.input.action).toBe("create_vocabulary");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.20.0",
"version": "1.23.0",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.20.0",
"version": "1.23.0",
"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": {
+1 -1
View File
@@ -217,7 +217,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
parsedFlags,
Object.keys(res.command.flags ?? {}),
) as ParsedFlags<FlagsDef>;
const invalid = res.command.validate?.(ownFlags);
const invalid = await res.command.validate?.(ownFlags);
if (invalid) throw new UsageError(invalid);
// 校验通过 → 建源、解析 settings、组 ctx,进中间件执行命令。
@@ -16,6 +16,22 @@ export interface PipelineEnv {
settings: Settings;
}
/** Media steps that inherit Profile `watermark` when the YAML omits the field. */
export const PIPELINE_WATERMARK_STEPS = new Set(["image/generate", "image/edit", "video/generate"]);
/**
* Fill Profile watermark into a planned/executed step input.
* Explicit step `watermark` wins; otherwise use Settings (file → default true).
*/
export function applyProfileWatermarkToStepInput(
stepType: string,
input: Record<string, unknown>,
settings: Settings,
): Record<string, unknown> {
if (!PIPELINE_WATERMARK_STEPS.has(stepType) || input.watermark !== undefined) return input;
return { ...input, watermark: settings.watermark };
}
/**
* Build the in-process env for pipeline steps. Uses the same source resolution
* as the CLI itself (env vars, config file; no CLI flags), but forces JSON
+9 -4
View File
@@ -1,5 +1,5 @@
import { PipelineError, toPipelineError } from "./errors.ts";
import { buildPipelineEnv } from "./bl-config.ts";
import { applyProfileWatermarkToStepInput, buildPipelineEnv } from "./bl-config.ts";
import { getDefaultStepDispatcher, type StepDispatcher } from "./dispatcher.ts";
import {
evaluateCondition,
@@ -156,12 +156,17 @@ async function executePipelineInternal(
if (options.dryRun) {
for (const planStep of topologicalOrder(plan)) {
const resolved = resolvePlannedStepInput(planStep.step, pipeline, normalizedRuntimeInput);
const plannedInput = applyProfileWatermarkToStepInput(
planStep.step.type,
resolved.redacted,
blEnv.settings,
);
const report: PipelineStepReport = {
id: planStep.step.id,
type: planStep.step.type,
status: "planned",
dependencies: planStep.dependencies,
input: resolved.redacted,
input: plannedInput,
...(planStep.step.when !== undefined ? { condition: "pending" } : {}),
};
reports.push(report);
@@ -170,14 +175,14 @@ async function executePipelineInternal(
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
input: inputSummary(plannedInput, resolved.sensitiveKeys),
});
await emit({
type: "step.planned",
timestamp: now(),
status: "planned",
step: stepEvent(planStep),
input: inputSummary(resolved.redacted, resolved.sensitiveKeys),
input: inputSummary(plannedInput, resolved.sensitiveKeys),
...(planStep.step.when !== undefined ? { condition: "pending" as const } : {}),
});
}
+25 -8
View File
@@ -12,6 +12,7 @@ import {
speechRecognizePath,
resolveAsrApi,
buildAsrFlashRequest,
buildAsrContextMessages,
buildAsyncAsrLanguageFields,
collectAsrTranscriptionItems,
extractAsrFlashText,
@@ -192,7 +193,8 @@ export async function imageGenerate(
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
};
const body: DashScopeImageRequest =
@@ -299,7 +301,8 @@ export async function imageEdit(
n,
seed: input.seed,
prompt_extend: promptExtend,
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
};
let body: DashScopeImageRequest;
@@ -460,7 +463,8 @@ export async function videoGenerate(
ratio: input.ratio || undefined,
duration: input.duration,
prompt_extend: resolveBooleanFlag(input["prompt-extend"], undefined, "prompt-extend"),
watermark: resolveWatermark(input.watermark),
// Step input overrides Profile; omit → use Profile / CLI default (true).
watermark: resolveWatermark(input.watermark, env.settings.watermark),
seed: input.seed,
},
};
@@ -563,6 +567,10 @@ export interface SpeechRecognizeInput {
diarization?: boolean;
"speaker-count"?: number;
"vocabulary-id"?: string;
/** Instant hot words (already structured; no JSON string parse needed). */
vocabulary?: Record<string, number>;
/** Context enhancement plain text. */
context?: string;
"channel-id"?: number;
"poll-interval"?: number;
}
@@ -602,9 +610,11 @@ export async function speechRecognize(
const unsupportedFlags: string[] = [];
if (input.diarization) unsupportedFlags.push("diarization");
if (input["speaker-count"] !== undefined) unsupportedFlags.push("speaker-count");
// input-audio Flash supports vocabulary_id; qwen3 sync Flash does not
if (route.flashFamily === "qwen3" && input["vocabulary-id"] !== undefined) {
unsupportedFlags.push("vocabulary-id");
// qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape
if (route.flashFamily === "qwen3") {
if (input["vocabulary-id"] !== undefined) unsupportedFlags.push("vocabulary-id");
if (input.vocabulary !== undefined) unsupportedFlags.push("vocabulary");
if (input.context !== undefined) unsupportedFlags.push("context");
}
if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id");
if (unsupportedFlags.length > 0) {
@@ -648,6 +658,8 @@ export async function speechRecognize(
audioUrl: fileUrls[0]!,
language: input.language,
vocabularyId: input["vocabulary-id"],
vocabulary: input.vocabulary,
context: input.context,
flashFamily,
});
const response = await env.client.requestJson<Record<string, unknown>>({
@@ -671,14 +683,19 @@ export async function speechRecognize(
);
const body: DashScopeASRRequest = {
model,
input:
route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls },
input: {
...(route.asyncInputStyle === "file_url"
? { file_url: fileUrls[0]! }
: { file_urls: fileUrls }),
...(input.context ? { context: buildAsrContextMessages(input.context) } : {}),
},
parameters: {
channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined,
...languageFields,
diarization_enabled: input.diarization,
speaker_count: input["speaker-count"],
vocabulary_id: input["vocabulary-id"],
vocabulary: input.vocabulary,
},
};
stripUndefined(body.parameters as Record<string, unknown>);
@@ -1,5 +1,9 @@
import { registerStep } from "../dispatcher.ts";
import { buildPipelineEnv, type PipelineEnv } from "../bl-config.ts";
import {
applyProfileWatermarkToStepInput,
buildPipelineEnv,
type PipelineEnv,
} from "../bl-config.ts";
import { isRecord } from "../utils.ts";
import {
textChat,
@@ -153,9 +157,13 @@ async function executeDirectBlStep(
input: Record<string, unknown>,
ctx: StepContext,
): Promise<StepResult> {
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
if (ctx.dryRun) {
// Surface the Profile-effective watermark in dry-run so plans match runtime.
const plannedInput = applyProfileWatermarkToStepInput(id, input, env.settings);
return {
metadata: { dryRun: true, step: id, plannedInput: input },
metadata: { dryRun: true, step: id, plannedInput },
warnings: [
{ code: "dry_run_skipped", message: `Step ${id} was not executed in dry-run mode` },
],
@@ -167,7 +175,6 @@ async function executeDirectBlStep(
throw new Error(`No direct API handler registered for step: ${id}`);
}
const env = (ctx.blEnv as PipelineEnv | undefined) ?? buildPipelineEnv();
const data = await handler(env, input, ctx);
const builder = RESULT_BUILDERS[id];
if (builder) {
@@ -1,8 +1,9 @@
/** Shared --foo <bool> help text; keep wording consistent with actual CLI/request behavior. */
export const BOOL_FLAG_WATERMARK = {
"en-US": "Enable watermark (true/false). Omit flag to use CLI default (true).",
"zh-CN": "是否启用水印(true/false)。不传时使用 CLI 默认值 true。",
"en-US":
"Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true).",
"zh-CN": "是否启用水印(true/false)。不传时使用 Profile 配置或 CLI 默认值 true。",
};
/** CLI sends prompt_extend=true when flag omitted (qwen-image edit, etc.). */
@@ -68,6 +68,81 @@ test("pipeline speechRecognize routes input-audio flash to sync multimodal endpo
});
});
test("pipeline speechRecognize injects vocabulary and context on sync input-audio", async () => {
const { env, captured } = makeEnv();
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash",
vocabulary: { 奋斗者: 4 },
context: "奋斗者号",
},
makeCtx(),
);
expect(captured[0]?.body).toMatchObject({
parameters: { vocabulary: { 奋斗者: 4 } },
input: {
messages: [
{ role: "user", content: [{ type: "input_text", text: "奋斗者号" }] },
{
role: "user",
content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }],
},
],
},
});
});
test("pipeline speechRecognize injects vocabulary and context on async filetrans", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
return { output: { task_id: "task-1", task_status: "PENDING" } };
}
return {
output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] },
request_id: "r1",
};
});
await speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen-audio-3.0-asr-flash-filetrans",
vocabulary: { 鲸落: 4 },
context: "鲸落 深海勇士",
"poll-interval": 0,
},
makeCtx(),
);
expect(captured[0]?.body).toMatchObject({
input: {
file_urls: ["https://example.com/a.wav"],
context: [{ role: "user", content: [{ type: "input_text", text: "鲸落 深海勇士" }] }],
},
parameters: { vocabulary: { 鲸落: 4 } },
});
});
test("pipeline speechRecognize rejects vocabulary on qwen3 sync flash", async () => {
const { env, captured } = makeEnv();
await expect(
speechRecognize(
env,
{
url: "https://example.com/a.wav",
model: "qwen3-asr-flash",
vocabulary: { 奋斗者: 4 },
},
makeCtx(),
),
).rejects.toBeInstanceOf(PipelineError);
expect(captured).toHaveLength(0);
});
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => {
const { env, captured } = makeEnv(async (opts) => {
if (opts.async || opts.method === "POST") {
@@ -0,0 +1,129 @@
import { expect, test } from "vite-plus/test";
import type { Client } from "bailian-cli-core";
import { applyProfileWatermarkToStepInput, type PipelineEnv } from "../src/pipeline/bl-config.ts";
import { imageEdit, imageGenerate, videoGenerate } from "../src/pipeline/steps/bl-api.ts";
import type { StepContext } from "../src/pipeline/types.ts";
type CapturedRequest = {
path?: string;
method?: string;
body?: {
parameters?: { watermark?: boolean };
};
async?: boolean;
};
function makeEnv(watermark: boolean): {
env: PipelineEnv;
captured: CapturedRequest[];
} {
const captured: CapturedRequest[] = [];
const client = {
uploadFile: async (source: string) => source,
requestJson: async (opts: CapturedRequest) => {
captured.push(opts);
if (opts.async) {
return { output: { task_id: "task-wm", task_status: "PENDING" } };
}
// Sync image response shape (qwen-image / wan2.7-image).
return {
request_id: "req-wm",
output: {
choices: [{ message: { content: [{ image: "https://example.com/out.png" }] } }],
},
};
},
} as unknown as Client;
return {
env: {
client,
settings: {
quiet: true,
output: "json",
outputExplicit: true,
timeout: 300,
watermark,
verbose: false,
dryRun: false,
telemetry: false,
} as PipelineEnv["settings"],
},
captured,
};
}
function makeCtx(): StepContext {
return { dryRun: false, signal: new AbortController().signal, timeoutSeconds: 1 };
}
test("pipeline imageGenerate inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline imageGenerate keeps step watermark over Profile", async () => {
const { env, captured } = makeEnv(false);
await imageGenerate(env, { prompt: "A cat", watermark: true }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
});
test("pipeline imageEdit inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
await imageEdit(env, { prompt: "Blue sky", image: "https://example.com/in.png" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline videoGenerate inherits Profile watermark=false when step omits it", async () => {
const { env, captured } = makeEnv(false);
// First call submits async task; pollTaskWithOptions will keep polling — mock SUCCEEDED quickly.
const client = env.client as unknown as {
requestJson: (opts: CapturedRequest) => Promise<unknown>;
};
let calls = 0;
client.requestJson = async (opts: CapturedRequest) => {
captured.push(opts);
calls += 1;
if (calls === 1) {
return { output: { task_id: "task-wm", task_status: "PENDING" } };
}
return {
output: {
task_id: "task-wm",
task_status: "SUCCEEDED",
video_url: "https://example.com/out.mp4",
},
};
};
await videoGenerate(
env,
{ prompt: "A cat walks", "poll-interval": 0 },
{ ...makeCtx(), timeoutSeconds: 5 },
);
expect(captured[0]?.body?.parameters?.watermark).toBe(false);
});
test("pipeline defaults watermark to true when Profile leaves the compliance default", async () => {
const { env, captured } = makeEnv(true);
await imageGenerate(env, { prompt: "A cat" }, makeCtx());
expect(captured[0]?.body?.parameters?.watermark).toBe(true);
});
test("applyProfileWatermarkToStepInput fills omitted watermark from Settings", () => {
const settings = { watermark: false } as PipelineEnv["settings"];
expect(
applyProfileWatermarkToStepInput("image/generate", { prompt: "A cat" }, settings).watermark,
).toBe(false);
expect(
applyProfileWatermarkToStepInput(
"image/generate",
{ prompt: "A cat", watermark: true },
settings,
).watermark,
).toBe(true);
expect(
applyProfileWatermarkToStepInput("text/chat", { message: "hi" }, settings).watermark,
).toBeUndefined();
});
+5 -5
View File
@@ -189,8 +189,8 @@ importers:
packages/commands:
dependencies:
'@openagentpack/sdk':
specifier: 0.5.0
version: 0.5.0
specifier: 0.7.1
version: 0.7.1
bailian-cli-core:
specifier: workspace:*
version: link:../core
@@ -1124,8 +1124,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@openagentpack/sdk@0.5.0':
resolution: {integrity: sha512-9mNMvPWuoiK5NQVFN5i2qAhUAFSmw9jO8xrMDXXfa6eSu0XlEAPZzR6DDg4HdhW+ex60KgA+fcNA0h0LRF552A==}
'@openagentpack/sdk@0.7.1':
resolution: {integrity: sha512-OENks8UdfWFanvhJIVbqyUX1+XGavHnrxDiFx4SoJoGRXi45cOgyYtWWbG5VdDI2I5Dpo9eGNaw/JtDnkdxUcg==}
engines: {node: '>=18.17.0'}
'@oxc-project/runtime@0.129.0':
@@ -4287,7 +4287,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@openagentpack/sdk@0.5.0':
'@openagentpack/sdk@0.7.1':
dependencies:
jszip: 3.10.1
yaml: 2.9.0
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-cli
metadata:
version: "1.20.0"
version: "1.23.0"
requires:
bins: ["bl"]
description: >-
+8 -4
View File
@@ -88,10 +88,10 @@ bl config list --output json
#### Flags
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <key>` | string | yes | Config key (language, base*url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) |
| `--value <value>` | string | yes | Value to set |
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <key>` | string | yes | Config key (language, base*url, output, output_dir, timeout, watermark, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) |
| `--value <value>` | string | yes | Value to set |
#### Examples
@@ -107,6 +107,10 @@ bl config set --key output --value json
bl config set --key timeout --value 600
```
```bash
bl config set --key watermark --value false
```
```bash
bl config set --key base_url --value https://dashscope.aliyuncs.com
```
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-finetune
metadata:
version: "1.20.0"
version: "1.23.0"
requires:
bins: ["bl"]
description: >-
+25 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-gen
metadata:
version: "1.20.0"
version: "1.23.0"
requires:
bins: ["bl"]
description: >-
@@ -45,8 +45,27 @@ Unless the user explicitly specifies a model, omit `--model` and let the CLI use
For ASR model selection, keep `fun-asr` (or other `*-filetrans`) for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to about five minutes when the user asks for low-latency Flash models, use `--model fun-asr-flash-2026-06-15`, `--model qwen-audio-3.0-asr-flash`, or `--model qwen3-asr-flash`. Flash recognition is synchronous and accepts exactly one file per call.
To improve ASR accuracy with domain terms:
- Prefer instant `--vocabulary` / `--context` on `bl speech recognize` when the model is Qwen-Audio-3.0-ASR-Flash series (and Fun-ASR-Flash for `--context` only) — no pre-built vocabulary needed. Start weights at 4 (do not default everything to 5). `--context` must list the target words themselves; a topic description alone has little effect.
- Use `bl speech vocabulary create` + `--vocabulary-id` for Fun-ASR / Paraformer, or whenever the same hot words must be reused across requests. The vocabulary `--model` must exactly match recognize `--model` (otherwise the vocabulary is silently ignored). Each account may have at most 10 vocabularies; delete unused ones.
Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl <command> --help` — do not guess flags.
## Watermark configuration
Image generation and editing, video generation and editing, and reference-to-video enable watermarks by default. Change the default for the active Profile with:
```bash
bl config set --key watermark --value false
```
Set it to `true` to enable watermarks again. To update a named Profile without switching Profiles, add `--config <name>`:
```bash
bl config set --config media --key watermark --value false
```
## Local files (mandatory)
Any command that accepts a **file URL** also accepts a **local path**; the CLI uploads to DashScope temporary storage (`oss://`, 48h) automatically. If the user gives a local file, pass the path directly — never ask them to upload or host a URL first.
@@ -68,6 +87,11 @@ bl vision describe --image ./photo.jpg --prompt "图里有什么?"
bl vision describe --video ./clip.mp4 --prompt "总结视频内容"
bl omni --message "Describe the video content" --video ./demo.mp4 --text-only
bl speech synthesize --text "Hello, welcome to Bailian" --out hello.mp3
bl speech recognize --url ./meeting.wav --model qwen-audio-3.0-asr-flash-filetrans \
--vocabulary '{"奋斗者":4}' --context "奋斗者号"
VOCAB=$(bl speech vocabulary create --model fun-asr --prefix demo --words '{"奋斗者":4}' --quiet)
bl speech recognize --url ./meeting.wav --model fun-asr --vocabulary-id "$VOCAB"
bl speech vocabulary delete --id "$VOCAB" --yes
```
## Output language
+2 -2
View File
@@ -36,7 +36,7 @@ Index: [index.md](index.md)
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--function <name>` | string | no | wanx\*-imageedit function (default: description_edit). Examples: stylization_all, description_edit |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--out-dir <dir>` | string | no | Download images to directory |
| `--out-prefix <prefix>` | string | no | Filename prefix (default: edited) |
| `--async` | switch | no | Return async task id without waiting |
@@ -99,7 +99,7 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach"
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--out-dir <dir>` | string | no | Download images to directory |
+25 -20
View File
@@ -9,29 +9,34 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| Command | Authentication | Description | Detail |
| ---------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) |
| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) |
| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) |
| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) |
| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
| `bl video task get` | API Key | Query async task status | [video.md](video.md) |
| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
| Command | Authentication | Description | Detail |
| ----------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) |
| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) |
| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) |
| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) |
| `bl speech vocabulary create` | API Key | Create a precompiled hot-word vocabulary for ASR | [speech.md](speech.md) |
| `bl speech vocabulary delete` | API Key | Delete a precompiled hot-word vocabulary | [speech.md](speech.md) |
| `bl speech vocabulary get` | API Key | Get details of a precompiled hot-word vocabulary | [speech.md](speech.md) |
| `bl speech vocabulary list` | API Key | List precompiled hot-word vocabularies | [speech.md](speech.md) |
| `bl speech vocabulary update` | API Key | Replace the contents of a precompiled hot-word vocabulary | [speech.md](speech.md) |
| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) |
| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) |
| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) |
| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) |
| `bl video task get` | API Key | Query async task status | [video.md](video.md) |
| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) |
## By group
| Group | Commands | Reference |
| -------- | ------------------------------------------------- | ---------------------- |
| `image` | `edit`, `generate` | [image.md](image.md) |
| `omni` | `(root)` | [omni.md](omni.md) |
| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) |
| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) |
| `vision` | `describe` | [vision.md](vision.md) |
| Group | Commands | Reference |
| -------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `image` | `edit`, `generate` | [image.md](image.md) |
| `omni` | `(root)` | [omni.md](omni.md) |
| `speech` | `recognize`, `synthesize`, `vocabulary create`, `vocabulary delete`, `vocabulary get`, `vocabulary list`, `vocabulary update` | [speech.md](speech.md) |
| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) |
| `vision` | `describe` | [vision.md](vision.md) |
## Global flags
+228 -18
View File
@@ -7,10 +7,15 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Authentication | Description |
| ---------------------- | -------------- | ----------------------------------------------------------------- |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) |
| `bl speech synthesize` | API Key | Synthesize speech from text |
| Command | Authentication | Description |
| ----------------------------- | -------------- | ----------------------------------------------------------------- |
| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) |
| `bl speech synthesize` | API Key | Synthesize speech from text |
| `bl speech vocabulary create` | API Key | Create a precompiled hot-word vocabulary for ASR |
| `bl speech vocabulary delete` | API Key | Delete a precompiled hot-word vocabulary |
| `bl speech vocabulary get` | API Key | Get details of a precompiled hot-word vocabulary |
| `bl speech vocabulary list` | API Key | List precompiled hot-word vocabularies |
| `bl speech vocabulary update` | API Key | Replace the contents of a precompiled hot-word vocabulary |
## Command details
@@ -25,20 +30,22 @@ Index: [index.md](index.md)
#### Flags
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Hot-word vocabulary ID for improved accuracy |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer |
| `--vocabulary <json>` | string | no | Instant hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models |
| `--context <text>` | string | no | Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -62,6 +69,14 @@ bl speech recognize --url https://example.com/audio.mp3 --language zh
bl speech recognize --url https://example.com/audio.mp3 --vocabulary-id vocab-abc123
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary '{"奋斗者":4,"鲸落":4}'
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士"
```
```bash
bl speech recognize --url https://example.com/audio.mp3 --out result.json
```
@@ -148,3 +163,198 @@ bl speech synthesize --text "Hello" --voice <voice_id> --stream | afplay -
```bash
bl speech synthesize --text "Hello" --voice <voice_id> --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -
```
### `bl speech vocabulary create`
| Field | Value |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| **Name** | `speech vocabulary create` |
| **Description** | Create a precompiled hot-word vocabulary for ASR |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary create --model <model> --prefix <prefix> (--words <json> \| --words-file <path>) [flags]` |
#### Flags
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--model <model>` | string | yes | ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored |
| `--prefix <prefix>` | string | yes | Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars |
| `--words <json>` | string | no | Hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file |
| `--words-file <path>` | string | no | JSON file with the hot words (use - for stdin) |
| `--lang <code>` | string | no | Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error.
- Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.
- Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.
- Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.
#### Examples
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4,"Jingluo":4}'
```
```bash
bl speech vocabulary create --model paraformer-v2 --prefix demo --words '[{"text":"Fendouzhe","weight":4,"lang":"zh"}]'
```
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4}' --lang zh
```
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words-file ./hotwords.json
```
```bash
bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4}' --quiet
```
### `bl speech vocabulary delete`
| Field | Value |
| ------------------ | -------------------------------------------------------------------------------- |
| **Name** | `speech vocabulary delete` |
| **Description** | Delete a precompiled hot-word vocabulary |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary delete --id <id>` |
| **Risk** | `high` |
| **Risk message** | This permanently deletes the specified hot-word vocabulary and cannot be undone. |
> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope.
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | --------------------------------- |
| `--id <id>` | string | yes | Hot-word vocabulary ID (required) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
```bash
bl speech vocabulary delete --id vocab-demo-xxx --dry-run
```
```bash
# Only after explicit user confirmation:
bl speech vocabulary delete --id vocab-demo-xxx --yes
```
### `bl speech vocabulary get`
| Field | Value |
| ------------------ | ------------------------------------------------ |
| **Name** | `speech vocabulary get` |
| **Description** | Get details of a precompiled hot-word vocabulary |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary get --id <id>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | --------------------------------- |
| `--id <id>` | string | yes | Hot-word vocabulary ID (required) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure.
#### Examples
```bash
bl speech vocabulary get --id vocab-demo-xxx
```
```bash
bl speech vocabulary get --id vocab-demo-xxx --quiet
```
### `bl speech vocabulary list`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------ |
| **Name** | `speech vocabulary list` |
| **Description** | List precompiled hot-word vocabularies |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary list [--prefix <prefix>] [--page <n>] [--page-size <n>]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------- | ------ | -------- | --------------------------------------------------------------------------------- |
| `--prefix <prefix>` | string | no | Filter by vocabulary prefix |
| `--page <n>` | number | no | Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1 |
| `--page-size <n>` | number | no | Results per page (default: 10) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for.
- Vocabularies with status UNDEPLOYED are silently ignored by ASR.
#### Examples
```bash
bl speech vocabulary list
```
```bash
bl speech vocabulary list --prefix demo
```
```bash
bl speech vocabulary list --page 2 --page-size 20
```
### `bl speech vocabulary update`
| Field | Value |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| **Name** | `speech vocabulary update` |
| **Description** | Replace the contents of a precompiled hot-word vocabulary |
| **Authentication** | API Key |
| **Usage** | `bl speech vocabulary update --id <id> (--words <json> \| --words-file <path>) [flags]` |
| **Risk** | `high` |
| **Risk message** | This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone. |
> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope.
#### Flags
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--id <id>` | string | yes | Hot-word vocabulary ID (required) |
| `--words <json>` | string | no | Hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file |
| `--words-file <path>` | string | no | JSON file with the hot words (use - for stdin) |
| `--lang <code>` | string | no | Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written.
- Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.
- Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.
- Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.
#### Examples
```bash
bl speech vocabulary update --id vocab-demo-xxx --words '{"Fendouzhe":4}' --dry-run
```
```bash
# Only after explicit user confirmation:
bl speech vocabulary update --id vocab-demo-xxx --words '{"Fendouzhe":4,"Jingluo":4}' --yes
```
+22 -22
View File
@@ -56,26 +56,26 @@ bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet
#### Flags
| Flag | Type | Required | Description |
| -------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------- |
| `--model <model>` | string | no | Model ID (default: happyhorse-1.0-video-edit) |
| `--video <url>` | string | yes | Input video URL or local file (mp4/mov, 2-10s) |
| `--prompt <text>` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") |
| `--ref-image <url>` | string | no | Reference image URL (up to 4, comma-separated) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <auto\|origin>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--poll-interval <seconds>` | number | no | Polling interval when waiting (default: 15) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------ |
| `--model <model>` | string | no | Model ID (default: happyhorse-1.0-video-edit) |
| `--video <url>` | string | yes | Input video URL or local file (mp4/mov, 2-10s) |
| `--prompt <text>` | string | no | Edit instruction (e.g. "Convert the scene to a claymation style") |
| `--ref-image <url>` | string | no | Reference image URL (up to 4, comma-separated) |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--resolution <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <auto\|origin>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
| `--concurrent <n>` | number | no | Run N parallel requests (default: 1) |
| `--poll-interval <seconds>` | number | no | Polling interval when waiting (default: 15) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Examples
@@ -117,7 +117,7 @@ bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the
| `--ratio <ratio>` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--file <url-or-path>` | string | no | Reference file URL or local path for file-to-video (wan3.0-video only; mutually exclusive with --image/--last-frame) |
@@ -172,7 +172,7 @@ bl video generate --prompt "A cat playing with a ball" --watermark false
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use the Profile setting or CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | string | no | Save video to file on completion |
| `--async` | switch | no | Return async task id without waiting |
+29 -3
View File
@@ -1,13 +1,14 @@
---
name: bailian-managed-agent
metadata:
version: "1.20.0"
version: "1.23.0"
requires:
bins: ["bl"]
description: >-
阿里云百炼托管 Agent 声明式基础设施与 API 命令入口:用户要创建agent、初始化 agents.yaml、校验或预览配置变更、
创建/更新/销毁托管 Agent 或 Deployment,或查询 Agent/Environment/Skill/Vault/Deployment、管理 Session/Event/File、
运行/暂停 Deployment 时使用 `bl managed-agent`。持久资源仍以 agents.yaml 为唯一事实源做 IaC;公开 API 能力按资源透出
创建/更新/销毁托管 Agent 或 Deployment、在 Workbench 编辑和调试目录项目、管理本地快照版本,或查询
Agent/Environment/Skill/Vault/Deployment、管理 Session/Event/File、运行/暂停 Deployment 时使用
`bl managed-agent`。持久资源仍以 agents.yaml 为唯一事实源做 IaC;公开 API 能力按资源透出
list/get/search/versions/download、数据面和运行时动作命令。apply / destroy 与破坏性 API 命令受统一高风险确认闸门保护;
务必先展示预览再让用户确认,禁止自动添加 `--yes`。
反触发:调用已上线的百炼应用/智能体走 bailian-app-call 或 `bl app`;宿主 agent 自身的记忆、技能、
@@ -47,6 +48,31 @@ for explicit confirmation before re-running with `--yes`.
6. Destroy bl managed-agent destroy --yes # separate explicit confirmation
```
## Directory projects, Workbench, and local versions
| Intent | Command |
| --------------------------------------- | ------------------------------------------------------ |
| Create or convert a directory project | `bl managed-agent project init` |
| Validate and Build directory source | `bl managed-agent project validate` / `build` |
| Publish the current immutable Build | `bl managed-agent project publish --yes` |
| Launch project resource editing | `bl managed-agent project workbench` |
| Launch one Agent Session Preview | `bl managed-agent playground --agent <id>` |
| Enable/disable project versions | `bl managed-agent project version enable` / `disable` |
| Inspect local version state and history | `bl managed-agent project version status` / `list` |
| Preview or restore project source | `bl managed-agent project version preview` / `restore` |
- Bailian CLI and Workbench use the same `.openagentpack/versions/project` store and enable switch. Git is not required.
- Directory projects always use Bailian. `project.json` does not declare a Provider; Build supplies the Bailian Provider configuration automatically.
- Fresh `project init` includes complete Skill/File/Vault/Environment examples with bilingual README files under `agents/assistant/<resource-type>/_examples/`. They are not linked in `agent.json` and are excluded from Build discovery, Workbench declarations, and remote Publish. Copy a resource outside `_examples/` and configure its Agent reference to use it. Examples remain local versioned source; never put real secrets into them. / 新项目的四类资源示例默认不启用、不发布;请按 README 复制到 `_examples/` 外再配置引用,不要向示例写入真实密钥。
- `project init`, `validate`, `build`, and version commands are local-only. Publish and Workbench resolve credentials from Bailian CLI flags, shell environment, or the active Profile; project initialization does not write credentials into the project directory.
- Build is local-only. Publish never runs Build implicitly and consumes only a current `.openagentpack/build/agents.yaml` plus manifest.
- Build moves literal Vault `secret_value` / `access_token` values from Agent-local or shared `vault.json` into project-root `.env`, replacing them with generated environment references. Existing references and `.env` entries are preserved; conflicts receive suffixed variable names. Preview/dry-run never write or print secrets. Publish and Workbench read the selected project's root `.env` as a fallback to inherited environment variables, even when invoked elsewhere. `.env` is private plaintext storage, excluded from local versions but not automatically ignored by Git; keep it backed up securely.
- Build 会将 Agent 本地或共享 `vault.json` 中的明文密钥移入项目根目录 `.env`,再写回环境变量引用;保留已有引用和变量,重名时生成后缀。预览不写文件或输出密钥。`.env` 不进入版本快照,也不加密;请自行备份并加入 Git 忽略规则。
- Agent-local File and Skill content supports Build-time association. A File may be copied directly into `agents/<agent>/files/`, or placed in `agents/<agent>/files/<id>/` when that directory contains exactly one content file; Build generates `file.json` and a `/mnt/<filename>` entry in `agent.json.files`. A directory under `agents/<agent>/skills/<id>/` containing `SKILL.md` generates `skill.json` and its `agent.json.skills` entry. Explicit JSON always wins; shared root resources remain explicit. Resources referenced by multiple Agents are promoted to the corresponding root shared directory during Build.
- A successful Publish versions the canonical YAML and the complete project source tree, including Skill scripts/assets and binary files. Remote State is never versioned or restored.
- `project version restore` restores source files to the working directory, invalidates Build, and does not move version history or remote State.
- `managed-agent playground` remains the standalone `agents.yaml` Session Preview path; directory Workbench is only under `managed-agent project workbench`.
## Scoped single-resource create
以下命令都先构造 `agents.yaml` 声明,再通过 SDK 的定向 Plan/Apply 创建远端资源,不绕过 State:
@@ -37,8 +37,20 @@ Use this index for the skill-scoped quick index and global flags.
| `bl managed-agent file list` | API Key | List Managed Agent files | [managed-agent.md](managed-agent.md) |
| `bl managed-agent file search` | API Key | Search Managed Agent files | [managed-agent.md](managed-agent.md) |
| `bl managed-agent file upload` | API Key | Upload a Managed Agent file | [managed-agent.md](managed-agent.md) |
| `bl managed-agent init` | No Auth | Create a new agents.yaml template | [managed-agent.md](managed-agent.md) |
| `bl managed-agent init` | No Auth | Create an agents.yaml template | [managed-agent.md](managed-agent.md) |
| `bl managed-agent plan` | API Key | Show what changes would be applied to agent infrastructure | [managed-agent.md](managed-agent.md) |
| `bl managed-agent playground` | API Key | Launch a Session Preview for an agents.yaml Agent | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project build` | No Auth | Organize directory source and generate the immutable Publish Build | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project init` | No Auth | Create a directory project or convert the local agents.yaml | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project publish` | API Key | Publish the current directory-project Build and record a version | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project validate` | No Auth | Validate a directory Agent project | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project version disable` | No Auth | Disable directory project versions | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project version enable` | No Auth | Enable directory project versions | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project version list` | No Auth | List directory project versions | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project version preview` | No Auth | Preview a directory project version | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project version restore` | No Auth | Restore a version to the project working directory | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project version status` | No Auth | Show directory project version status | [managed-agent.md](managed-agent.md) |
| `bl managed-agent project workbench` | API Key | Launch the directory project Workbench | [managed-agent.md](managed-agent.md) |
| `bl managed-agent session archive` | API Key | Archive a Managed Agent session | [managed-agent.md](managed-agent.md) |
| `bl managed-agent session create` | API Key | Create a new session for an agent | [managed-agent.md](managed-agent.md) |
| `bl managed-agent session debug` | API Key | Aggregate session diagnostics | [managed-agent.md](managed-agent.md) |
@@ -74,9 +86,9 @@ Use this index for the skill-scoped quick index and global flags.
## By group
| Group | Commands | Reference |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `managed-agent` | `agent create`, `agent get`, `agent list`, `agent search`, `agent versions`, `apply`, `deployment create`, `deployment get`, `deployment list`, `deployment pause`, `deployment run`, `deployment runs get`, `deployment runs list`, `deployment search`, `deployment unpause`, `destroy`, `environment create`, `environment get`, `environment list`, `environment search`, `file delete`, `file download`, `file get`, `file list`, `file search`, `file upload`, `init`, `plan`, `session archive`, `session create`, `session debug`, `session delete`, `session event list`, `session event send`, `session event stream`, `session events`, `session export`, `session get`, `session list`, `session run`, `session search`, `session send`, `session update`, `skill create`, `skill download`, `skill get`, `skill list`, `skill search`, `skill versions`, `skill-list`, `state import`, `state list`, `state rm`, `state show`, `validate`, `vault create`, `vault credential create`, `vault get`, `vault list`, `vault search` | [managed-agent.md](managed-agent.md) |
| Group | Commands | Reference |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| `managed-agent` | `agent create`, `agent get`, `agent list`, `agent search`, `agent versions`, `apply`, `deployment create`, `deployment get`, `deployment list`, `deployment pause`, `deployment run`, `deployment runs get`, `deployment runs list`, `deployment search`, `deployment unpause`, `destroy`, `environment create`, `environment get`, `environment list`, `environment search`, `file delete`, `file download`, `file get`, `file list`, `file search`, `file upload`, `init`, `plan`, `playground`, `project build`, `project init`, `project publish`, `project validate`, `project version disable`, `project version enable`, `project version list`, `project version preview`, `project version restore`, `project version status`, `project workbench`, `session archive`, `session create`, `session debug`, `session delete`, `session event list`, `session event send`, `session event stream`, `session events`, `session export`, `session get`, `session list`, `session run`, `session search`, `session send`, `session update`, `skill create`, `skill download`, `skill get`, `skill list`, `skill search`, `skill versions`, `skill-list`, `state import`, `state list`, `state rm`, `state show`, `validate`, `vault create`, `vault credential create`, `vault get`, `vault list`, `vault search` | [managed-agent.md](managed-agent.md) |
## Global flags
@@ -35,8 +35,20 @@ Index: [index.md](index.md)
| `bl managed-agent file list` | API Key | List Managed Agent files |
| `bl managed-agent file search` | API Key | Search Managed Agent files |
| `bl managed-agent file upload` | API Key | Upload a Managed Agent file |
| `bl managed-agent init` | No Auth | Create a new agents.yaml template |
| `bl managed-agent init` | No Auth | Create an agents.yaml template |
| `bl managed-agent plan` | API Key | Show what changes would be applied to agent infrastructure |
| `bl managed-agent playground` | API Key | Launch a Session Preview for an agents.yaml Agent |
| `bl managed-agent project build` | No Auth | Organize directory source and generate the immutable Publish Build |
| `bl managed-agent project init` | No Auth | Create a directory project or convert the local agents.yaml |
| `bl managed-agent project publish` | API Key | Publish the current directory-project Build and record a version |
| `bl managed-agent project validate` | No Auth | Validate a directory Agent project |
| `bl managed-agent project version disable` | No Auth | Disable directory project versions |
| `bl managed-agent project version enable` | No Auth | Enable directory project versions |
| `bl managed-agent project version list` | No Auth | List directory project versions |
| `bl managed-agent project version preview` | No Auth | Preview a directory project version |
| `bl managed-agent project version restore` | No Auth | Restore a version to the project working directory |
| `bl managed-agent project version status` | No Auth | Show directory project version status |
| `bl managed-agent project workbench` | API Key | Launch the directory project Workbench |
| `bl managed-agent session archive` | API Key | Archive a Managed Agent session |
| `bl managed-agent session create` | API Key | Create a new session for an agent |
| `bl managed-agent session debug` | API Key | Aggregate session diagnostics |
@@ -1068,7 +1080,7 @@ bl managed-agent file upload --path ./report.pdf
| Field | Value |
| ------------------ | ----------------------------------------------------------------------- |
| **Name** | `managed-agent init` |
| **Description** | Create a new agents.yaml template |
| **Description** | Create an agents.yaml template |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent init [--agent-name <name>] [--file <path>] [--force]` |
@@ -1126,6 +1138,372 @@ bl managed-agent plan
bl managed-agent plan --no-refresh
```
### `bl managed-agent playground`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------- |
| **Name** | `managed-agent playground` |
| **Description** | Launch a Session Preview for an agents.yaml Agent |
| **Authentication** | API Key |
| **Usage** | `bl managed-agent playground [--file <path>] [--agent <id>] [--port <n>] [--no-open]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | --------------------------------------------------------------------- |
| `--file <path>` | string | no | Config file path (default: agents.yaml) |
| `--port <n>` | number | no | Local port (default: 4848) |
| `--no-open` | switch | no | Do not open a browser automatically |
| `--agent <id>` | string | no | Agent to preview (required when the project declares multiple Agents) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- bl managed-agent supports the Bailian provider only; configurations containing other providers are rejected.
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
- Session Preview requires Node.js 22+ and keeps using an agents.yaml source. Directory Workbench is available under managed-agent project workbench.
#### Examples
```bash
bl managed-agent playground
```
```bash
bl managed-agent playground --agent assistant
```
```bash
bl managed-agent playground --file agents.yaml --no-open
```
### `bl managed-agent project build`
| Field | Value |
| ------------------ | ------------------------------------------------------------------ |
| **Name** | `managed-agent project build` |
| **Description** | Organize directory source and generate the immutable Publish Build |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project build [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
#### Notes
- Build writes local project files without confirmation, including inferred resource associations and migration of plaintext Vault secrets into .env. Use --dry-run to preview without writing. Publish still requires explicit confirmation before remote changes.
#### Examples
```bash
bl managed-agent project build
```
```bash
bl managed-agent project build --dry-run
```
```bash
bl managed-agent project build --project ./my-agent
```
### `bl managed-agent project init`
| Field | Value |
| ------------------ | ----------------------------------------------------------- |
| **Name** | `managed-agent project init` |
| **Description** | Create a directory project or convert the local agents.yaml |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project init [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | ----------------------------------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: ./managed-agent under the current directory) |
#### Notes
- Without --project, creates a managed-agent/ subdirectory. Enter it before running other project commands. Use --project . to initialize in place or convert the current agents.yaml; existing project files are not overwritten.
- New projects include Skill, File, Vault, and Environment examples under each resource directory's \_examples/. They are not referenced by agent.json and are excluded from Build/Publish. Copy an example outside \_examples/ to enable it, then configure its Agent reference.
#### Examples
```bash
bl managed-agent project init
```
```bash
bl managed-agent project init --project ./my-agent
```
```bash
bl managed-agent project init --project .
```
### `bl managed-agent project publish`
| Field | Value |
| ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| **Name** | `managed-agent project publish` |
| **Description** | Publish the current directory-project Build and record a version |
| **Authentication** | API Key |
| **Usage** | `bl managed-agent project publish [--project <directory>] [--no-refresh] [--concurrency <n>]` |
| **Risk** | `high` |
| **Risk message** | This publishes the current directory-project Build and may create, update, or delete remote managed Agent resources. |
> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope.
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
| `--no-refresh` | switch | no | Skip remote refresh before planning |
| `--concurrency <n>` | number | no | Maximum parallel resource operations |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- bl managed-agent supports the Bailian provider only; configurations containing other providers are rejected.
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
```bash
# Only after explicit user confirmation:
bl managed-agent project publish --yes
```
```bash
# Only after explicit user confirmation:
bl managed-agent project publish --project ./my-agent --yes
```
### `bl managed-agent project validate`
| Field | Value |
| ------------------ | ----------------------------------------------------------- |
| **Name** | `managed-agent project validate` |
| **Description** | Validate a directory Agent project |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project validate [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
#### Examples
```bash
bl managed-agent project validate
```
```bash
bl managed-agent project validate --project ./my-agent
```
### `bl managed-agent project version disable`
| Field | Value |
| ------------------ | ------------------------------------------------------------------ |
| **Name** | `managed-agent project version disable` |
| **Description** | Disable directory project versions |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project version disable [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
#### Examples
```bash
bl managed-agent project version disable
```
```bash
bl managed-agent project version disable --project ./my-agent
```
### `bl managed-agent project version enable`
| Field | Value |
| ------------------ | ----------------------------------------------------------------- |
| **Name** | `managed-agent project version enable` |
| **Description** | Enable directory project versions |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project version enable [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
#### Examples
```bash
bl managed-agent project version enable
```
```bash
bl managed-agent project version enable --project ./my-agent
```
### `bl managed-agent project version list`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------- |
| **Name** | `managed-agent project version list` |
| **Description** | List directory project versions |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project version list [--project <directory>] [--limit <n>] [--cursor <cursor>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
| `--limit <n>` | number | no | Maximum versions to return |
| `--cursor <cursor>` | string | no | Pagination cursor |
#### Examples
```bash
bl managed-agent project version list
```
```bash
bl managed-agent project version list --limit 20 --output json
```
### `bl managed-agent project version preview`
| Field | Value |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| **Name** | `managed-agent project version preview` |
| **Description** | Preview a directory project version |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project version preview --version-id <full-version> [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
| `--version-id <full-version>` | string | yes | Full project version ID |
#### Examples
```bash
bl managed-agent project version preview --version-id <full-version>
```
### `bl managed-agent project version restore`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| **Name** | `managed-agent project version restore` |
| **Description** | Restore a version to the project working directory |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project version restore --version-id <full-version> [--project <directory>]` |
| **Risk** | `high` |
| **Risk message** | This restores the full directory source to the working tree. Version history and remote State will not move. |
> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope.
#### Flags
| Flag | Type | Required | Description |
| ----------------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
| `--version-id <full-version>` | string | yes | Full project version ID |
| `--yes` | switch | no | Confirm this high-risk operation |
#### Examples
```bash
bl managed-agent project version restore --version-id <full-version>
```
```bash
# Only after explicit user confirmation:
bl managed-agent project version restore --version-id <full-version> --yes
```
### `bl managed-agent project version status`
| Field | Value |
| ------------------ | ----------------------------------------------------------------- |
| **Name** | `managed-agent project version status` |
| **Description** | Show directory project version status |
| **Authentication** | No Auth |
| **Usage** | `bl managed-agent project version status [--project <directory>]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
#### Examples
```bash
bl managed-agent project version status
```
```bash
bl managed-agent project version status --project ./my-agent --output json
```
### `bl managed-agent project workbench`
| Field | Value |
| ------------------ | ------------------------------------------------------------------------------------- |
| **Name** | `managed-agent project workbench` |
| **Description** | Launch the directory project Workbench |
| **Authentication** | API Key |
| **Usage** | `bl managed-agent project workbench [--project <directory>] [--port <n>] [--no-open]` |
#### Flags
| Flag | Type | Required | Description |
| ----------------------- | ------ | -------- | --------------------------------------------------- |
| `--project <directory>` | string | no | Directory project root (default: current directory) |
| `--port <n>` | number | no | Local port (default: 4848) |
| `--no-open` | switch | no | Do not open a browser |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
#### Notes
- bl managed-agent supports the Bailian provider only; configurations containing other providers are rejected.
- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
#### Examples
```bash
bl managed-agent project workbench
```
```bash
bl managed-agent project workbench --project ./my-agent --no-open
```
### `bl managed-agent session archive`
| Field | Value |
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-protocol
metadata:
version: "1.20.0"
version: "1.23.0"
requires:
bins: ["bl"]
description: >-
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-web-search
metadata:
version: "1.20.0"
version: "1.23.0"
requires:
bins: ["bl"]
description: >-