feat(knowledge): 新增知识库管理及用户旅程端到端测试支持

- 增加test:journey脚本,覆盖知识库跨命令全链路用户旅程测试
- 在文档中新增Journey E2E章节,详细说明用户旅程测试定位及断言机制
- 完善commands模块,新增知识库相关命令包括知识库列表、信息、创建、更新、删除
- 新增知识库文档相关命令,如文档列表、状态、上传、删除、打标签及OSS导入
- 添加知识服务管理命令,支持列表、创建、更新、部署、删除及复制
- 支持知识块增删查改命令,完善知识点的灵活操作能力
- 实现数据中心分类管理命令,支持分类增删查操作
- 优化knowledge chat命令,增加workspace-id统一解析及agent-version版本控制
- 重构与知识库相关命令的导出与注册,完善CLI整体能力覆盖
- 新增命令详尽的帮助文档,包含参数说明、使用示例及错误边界
- 实现批量删除知识块的自动分批处理逻辑,易于操作大规模数据
- 添加必要的输入校验与安全提示,确保操作安全且符合规范
This commit is contained in:
zeyu.fz
2026-08-05 12:02:32 +08:00
parent b1908fa879
commit 43abf0aca5
83 changed files with 11207 additions and 139 deletions
+10 -1
View File
@@ -6,6 +6,7 @@
| --------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **共享基建** | `packages/e2e` | gating、子进程 runner、output、globalSetup`private`,不发布) |
| **命令 E2E** | `packages/commands/tests/e2e` | help、缺参、dry-run、livegated每用例最小路由 |
| **Journey E2E** | `packages/commands/tests/e2e/knowledge/journeys` | 用户旅程全链路(跨命令回路 + 标记词召回闭环),全部 live gated`journeys/README.md` |
| **bl smoke** | `packages/cli/tests/e2e/registry.smoke.e2e.test.ts` | 产品 map 全部 path `--help`、分组 help、根 help |
| **kscli smoke** | `packages/kscli/tests/e2e/registry.smoke.e2e.test.ts` | 从 `kscli/src/commands.ts` 推导 path/分组identity`--version``search --help` path |
| **runtime** | `packages/runtime/tests` | `proxy.e2e`、console 跨域 flag 拒绝 |
@@ -27,7 +28,7 @@
### commands E2E
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`
- 路径:`packages/commands/tests/e2e/<kebab-topic>.e2e.test.ts`knowledge 领域集中在 `packages/commands/tests/e2e/knowledge/` 子目录(新增 knowledge 命令测试放这里)
- 子进程:`runCommandE2e(routes, args)` from `./helpers.ts`spawn `harness/main.ts``routes` 为本 topic 最小 path → export 映射)
- fixtures`packages/commands/tests/e2e/fixtures/`
- 路由常量:`topic-routes.ts`(按 topic 维护,**非**全量产品 map
@@ -78,6 +79,14 @@ describe.skipIf(<ready>)("e2e: <topic>DashScope …)", () => {
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
4. **真实集成**:放在 skip 块**末尾**
## Journey 层(用户旅程全链路)
- **定位**:命令 E2E 验单命令契约journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复
- **闭环断言**fixture 埋独特标记词,以“标记词能否被召回”判定回路闭合;硬断言 fail软断言 `recordSoft` 落报告人工复核
- **日志产物**`createJourneyReporter``test/output/<session>/` 落盘 `journey-report.md`、分步 stdout/stderr、`resources.json`(未清理资源警示)
- **入口**`pnpm run test:journey`;旅程清单与约定见 [journeys/README.md](../../packages/commands/tests/e2e/knowledge/journeys/README.md)
- **新增命令时**:评估是否属于某条旅程的环节,是则纳入对应 journey 并更新 README 映射表
## 增删命令同步
- **commands export** + **topic 路由**`topic-routes.ts` 或测试文件内 `ROUTES`+ **产品 map**`cli/commands.ts` / `kscli/commands.ts`
+1
View File
@@ -21,6 +21,7 @@
"bl": "pnpm -F bailian-cli dev",
"kscli": "pnpm -F knowledge-studio-cli dev",
"test": "vp test",
"test:journey": "vp test packages/commands/tests/e2e/knowledge/journeys",
"release:check": "node tools/release/check.mjs",
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
"test:stress": "node packages/cli/tests/stress/run.mjs"
+160
View File
@@ -0,0 +1,160 @@
# 迭代一设计 · doc 组命令
> 命令:`doc upload` / `doc list` / `doc status` / `doc delete` / `doc tag` / `doc import-oss`
> 公共约定见 [README.md](README.md)。
## doc upload — 上传本地文件入库(编排命令)
**说明**:本迭代最复杂命令。把"本地文件 → 数据中心 →(可选)导入知识库"封装为一条命令替代构建期最高频的控制台操作S2.2 痛点:高)。对标竞品 add-file。
**编排四步**
| 步 | API | 输入 | 输出 |
| ---------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------- |
| 1 申请租约 | `POST /api/v1/connector/dash/applyFileUploadLease` | `category`(类目ID) + `fileName` + `sizeBytes`(字符串!) + `contentMd5`(Base64) | `leaseId` + `param.url/method/headers` |
| 2 OSS 上传 | `PUT {param.url}` | 文件二进制 + `param.headers`(含 `x-bailian-extra``Content-Type` | HTTP 200 |
| 3 注册文件 | `POST /api/v1/connector/dash/addFile` | `leaseId` + `category` + `parser: "AUTO_SELECT"` + `tags?` | `fileId` |
| 4 导入(可选,传 `--index-id` 时) | `POST /api/v1/indices/rag/index/job/create` | `indexId` + `dataSource: { sourceType: "DATA_CENTER_FILE", fileIds }` | `ingestionId` |
坑位(实现注释必须标注):
- `sizeBytes` 必须字符串;`contentMd5` = `crypto.createHash("md5").update(buf).digest("base64")`
- 租约/注册的类目参数名是 `category`,不是 `categoryId`
- 第 4 步 body 是嵌套 `dataSource: { sourceType, fileIds }`(实测;公开文档的平铺 `documentIds` 会报 `Index.InvalidParameter`
- **第 4 步必须显式传 `sourceType`不传会导入整个数据中心API 文档明示的默认行为)**
- 步骤 2 走 OSS 域名不走 DashScope 网关,用原生 fetch 而非 ctx.client无 Bearer 头);失败归类 NETWORK
**Flags**
| flag | 类型 | 必填 | 说明 |
| -------------------------------------------------- | ------ | ---- | --------------------------------------------------------------------------------------------------------------------- |
| `--file <path>` | array | 是 | 本地文件路径,可重复;扩展名与大小按产品支持范围预校验(见下方格式白名单) |
| `--index-id <id>` | string | 否 | 注册后立即导入该知识库(触发第 4 步,多文件合并为一个 job |
| `--category-id <id>` | string | 否 | 目标类目缺省自动解析默认类目listCategory 取 `isDefault: true`),解析失败报 GENERAL + hint 显式传 `--category-id` |
| `--tag <text>` | array | 否 | addFile tags可重复 |
| `--wait` / `--poll-interval <s>` / `--timeout <s>` | — | 否 | 与 `--index-id` 联用,轮询 job status 至终态 |
**validate**`--wait``--index-id` → USAGE文件不存在/不可读 → GENERAL + errno hint沿用错误边界规范
**格式白名单与大小预校验**(依据 data/documents.md「支持的格式」读文件前拦截避免白传 OSS
| 类型 | 扩展名 | 硬限(超限 USAGE |
| ------ | -------------------------- | ----------------------------------------------------- |
| 文档 | .doc .docx .ppt .pptx .pdf | 150 MB |
| 表格 | .xls .xlsx | 10 MB产品为“建议值”超限降级为 stderr 警告不拦截) |
| 图片 | .png .jpg .jpeg .bmp .gif | 20 MB尺寸约束不做客户端校验留服务端 |
| 纯文本 | .md .txt .html | 10 MB同表格警告不拦截 |
- 扩展名不在白名单 → USAGE错误信息列出支持格式白名单常量独立导出便于后续随产品更新
- 开放问题create-kb.md 提及 .csv 但 documents.md 格式表未列——文档口径不一致,实现前向产品确认;确认前 .csv 暂入白名单(服务端拒绝会透传)
**输出**
- text每文件一行 `<fileName> <fileId> registered`;有导入时追加 `job: <ingestionId>`--wait 结束追加终态
- json`{ files: [{path, fileId}], index_id?, ingestion_id?, final_status? }`(编排命令无单一响应可透传,输出自定义稳定结构)
- quiet仅 fileId 每行一个
**实现方案**
- 文件 `doc-upload.ts`;多文件串行执行 1-3 步(首版不并发,避免 OSS 限流复杂化),全部注册成功后合并执行第 4 步
- 部分失败语义:任一文件步骤 1-3 失败即中止并报错,已成功的 fileId 列入错误 hint幂等重传代价低
- 默认类目解析结果进程内缓存(多文件只查一次)
- dry-run不读文件内容size/md5 以占位符表示),输出四步编排计划 `{ steps: [{step, endpoint, request}] }`
**测试方案**
- help / 缺 `--file` exitCode 2 / `--wait``--index-id` exitCode 2
- 文件不存在 → 非零退出 + ENOENT hint`.zip` 扩展名 → USAGE 列出支持格式
- dry-run断言 steps 长度(带/不带 --index-id 为 4/3、lease 请求 `sizeBytes` 为字符串类型、job 请求含 `sourceType: "DATA_CENTER_FILE"`
- live上传 1KB 临时 md 文件 → 断言 fileId 前缀 `file_` → afterAll doc delete + 数据中心 deleteFile 清理
## doc list — 查询知识库文档列表
**说明**:列出库内文档及解析/索引状态,含 FAILED 发现S2.3 / S5.2)。
**API**`GET /api/v1/indices/rag/index/files`query string`index_id` + `page_num`(注意本接口是 page_num+ `page_size`(默认 10最大 100
**Flags**`--index-id` 必填;`--page-number` / `--page-size`
**输出**
- text每行 `doc_id status doc_name doc_type size`status=FAILED 行红色高亮TTY尾行 `total: N`
- json 透传quiet 仅 doc_id
**实现/测试**:单 API 直映射(`doc-list.ts`dry-run 断言 query 参数名为 `page_num`live 断言 rows 结构与 doc_id 前缀。
## doc status — 查询导入任务状态
**说明**:查导入任务进度,`--wait` 阻塞至终态供脚本串行S2.3 痛点:高L3 验收FAILED 时非零 exit code
**API**`GET /api/v1/indices/rag/index_job/status`query string`index_id` + `job_id`**双必填,仅传其一服务端返回 SystemError客户端前置双校验拦截**+ 分页参数。
**Flags**
| flag | 必填 | 说明 |
| -------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------- |
| `--index-id <id>` | 是 | 知识库 ID |
| `--job-id <id>` | 是 | 导入任务 IDkb create / doc upload 返回的 ingestionId也见 doc list 的 ingestion_id |
| `--page-number` / `--page-size` | 否 | 任务含大量文档时分页 |
| `--wait` / `--poll-interval <s>`(默认 5) / `--timeout <s>`(默认 600) | 否 | 轮询至终态 |
**行为**
- 终态 FINISH → exit 0FAILED → `BailianError(GENERAL)` 透传服务端 message含文档级失败明细摘要exit 1
- `--wait` 超时 → TIMEOUT(5)
- 已知行为:库无进行中任务时接口可能返回 SystemError——hint 引导 "check ingestion_id via doc list"
**输出**text 顶部任务总状态 + 文档级状态列表FAILED 高亮json 透传。
**测试方案**help / 缺任一必填(两条用例)/ dry-run 断言 query 含两个 id / live配合 upload 用例拿真实 job 轮询到 FINISH`--wait --timeout 1` 对慢任务断言 exitCode 5若不稳定则仅静态覆盖超时路径live 标记 skip 原因)。
## doc delete — 删除文档【危险操作】
**说明**从知识库删除文档及其全部切片S5.1 内容更新循环)。
**API**`POST /api/v1/indices/rag/index/delete_file`body `{ index_id, doc_ids }`snake_case。响应 `data.deleted[]` 为实际删除列表。
**Flags**`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`
**实现方案**`doc-delete.ts`;确认摘要含 index_id + doc_id 列表≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
**测试方案**help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY 无 `--yes` exitCode 2 / live 配合 upload 清理链。
## doc tag — 批量更新文档标签
**说明**批量打标支撑标签过滤检索S2.4)。
**API**`POST /api/v1/connector/dash/batchUpdateFileTag``fileInfos`1-20 项,每项 `fileId` + `tags`,单标签 ≤32 字符、单文件 ≤100 个、总长 ≤700+ `updateMode`OVERWRITE/APPEND
**Flags**
| flag | 必填 | 说明 |
| --------------- | ---- | ------------------------------------------------------------------------- |
| `--doc-id <id>` | 是 | 可重复1-20 个(客户端预校验),映射 fileInfos[].fileId |
| `--tag <text>` | 是 | 可重复,应用到所有 `--doc-id`(首版同一组标签批量打;异构标签用多次调用) |
| `--mode <m>` | 否 | choices: `overwrite`/`append`,默认 `append`(追加比覆盖安全,作为缺省) |
**实现/测试**`doc-tag.ts` 单 API 直映射客户端预校验标签长度约束USAGE 前置拦截dry-run 断言 `updateMode: "APPEND"` 大写映射与 fileInfos 结构live 打标后 listFile/describeFile 验证回读。
## doc import-oss — 从授权 OSS 批量导入
**说明**:从已 SLR 授权的 OSS Bucket 批量导入数据中心(大客户批量场景)。
**API**`POST /api/v1/connector/dash/addFilesFromAuthorizedOss`。必填 `categoryId/categoryType/ossBucket/ossRegionId/fileDetails`1-10 项,每项 `fileName+ossKey`)。返回 `data.fileIds`
**Flags**
| flag | 必填 | 说明 |
| -------------------- | ---- | -------------------------------------------- |
| `--bucket <name>` | 是 | 映射 ossBucket |
| `--region <id>` | 是 | 映射 ossRegionId如 cn-beijing |
| `--oss-key <key>` | 是 | 可重复1-10 个fileName 取 key 的 basename |
| `--category-id <id>` | 否 | 缺省走默认类目解析(复用 upload 的解析函数) |
| `--tag <text>` | 否 | 可重复≤10 |
| `--overwrite` | 否 | switch映射 overWriteFileByOssKey |
固定值:`categoryType: "UNSTRUCTURED"``parser` 不暴露(默认 AUTO_SELECT审慎原则——DASH_QWEN_VL_PARSER 等需配 parserConfig使用方式未验证
**错误边界**SLR 未授权的服务端权限错误原样透传hint 附 RAM 控制台确认 `AliyunServiceRoleForBailian` 的指引(该指引来自 API 文档 Note属可权威解释范围
**实现/测试**`doc-import-oss.ts` 单 API 直映射dry-run 断言 fileDetails 结构与 fileName 派生逻辑live 依赖 OSS 授权环境gating 追加 `BAILIAN_E2E_OSS_BUCKET` 环境变量,无则 skip。
+64
View File
@@ -33,6 +33,37 @@ import {
knowledgeRetrieve,
knowledgeSearch,
knowledgeChat,
knowledgeKbList,
knowledgeKbInfo,
knowledgeDocList,
knowledgeDocStatus,
knowledgeDocUpload,
knowledgeKbCreate,
knowledgeKbUpdate,
knowledgeKbDelete,
knowledgeDocDelete,
knowledgeDocTag,
knowledgeServiceList,
knowledgeServiceGet,
knowledgeServiceCreate,
knowledgeServiceUpdate,
knowledgeServiceDeploy,
knowledgeServiceDelete,
knowledgeServiceCopy,
knowledgeChunkAdd,
knowledgeChunkList,
knowledgeChunkUpdate,
knowledgeChunkDelete,
knowledgeKbStats,
knowledgeCategoryList,
knowledgeCategoryAdd,
knowledgeCategoryDelete,
knowledgeFileList,
knowledgeFileGet,
knowledgeFileDelete,
knowledgeCollectionCreate,
knowledgeCollectionGet,
knowledgeDocImportOss,
mcpCall,
mcpList,
mcpTools,
@@ -151,6 +182,39 @@ export const commands: Record<string, AnyCommand> = {
"knowledge retrieve": knowledgeRetrieve,
"knowledge search": knowledgeSearch,
"knowledge chat": knowledgeChat,
"knowledge list": knowledgeKbList,
"knowledge info": knowledgeKbInfo,
"knowledge create": knowledgeKbCreate,
"knowledge update": knowledgeKbUpdate,
"knowledge delete": knowledgeKbDelete,
"knowledge doc list": knowledgeDocList,
"knowledge doc status": knowledgeDocStatus,
"knowledge doc upload": knowledgeDocUpload,
"knowledge doc delete": knowledgeDocDelete,
"knowledge doc tag": knowledgeDocTag,
"knowledge service list": knowledgeServiceList,
"knowledge service get": knowledgeServiceGet,
"knowledge service create": knowledgeServiceCreate,
"knowledge service update": knowledgeServiceUpdate,
"knowledge service deploy": knowledgeServiceDeploy,
"knowledge service delete": knowledgeServiceDelete,
"knowledge service copy": knowledgeServiceCopy,
"knowledge chunk add": knowledgeChunkAdd,
"knowledge chunk list": knowledgeChunkList,
"knowledge chunk update": knowledgeChunkUpdate,
"knowledge chunk delete": knowledgeChunkDelete,
"knowledge stats": knowledgeKbStats,
"knowledge doc import-oss": knowledgeDocImportOss,
// Data-center commands live under knowledge (no separate connector namespace);
// the user-facing term for connector is "collection".
"knowledge collection create": knowledgeCollectionCreate,
"knowledge collection get": knowledgeCollectionGet,
"knowledge category list": knowledgeCategoryList,
"knowledge category add": knowledgeCategoryAdd,
"knowledge category delete": knowledgeCategoryDelete,
"knowledge file list": knowledgeFileList,
"knowledge file get": knowledgeFileGet,
"knowledge file delete": knowledgeFileDelete,
"mcp call": mcpCall,
"mcp list": mcpList,
"mcp tools": mcpTools,
@@ -0,0 +1,79 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAddCategoryResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_ADD_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Category name (1-20 chars)",
required: true,
},
parentId: {
type: "string",
valueHint: "<id>",
description: "Create as a sub-category of this category",
},
collectionId: {
type: "string",
valueHint: "<id>",
description: "Create under this collection (defaults to the platform collection)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Create a data-center category",
auth: "apiKey",
usageArgs: "--name <text> [flags]",
flags: CATEGORY_ADD_FLAGS,
notes: ["Use categories to organize data-center files by business domain."],
exampleArgs: ["--name product-docs --workspace-id ws-xxx", "--name sub --parent-id cate-xxx"],
validate(flags) {
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// categoryType fixed to UNSTRUCTURED (the only valid value for knowledge-base creation today)
const body = {
categoryName: flags.name,
categoryType: "UNSTRUCTURED",
...(flags.parentId ? { parentCategoryId: flags.parentId } : {}),
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addCategory);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAddCategoryResponse>({
path: endpoint,
method: "POST",
body,
});
const categoryId = response.data?.categoryId;
if (settings.quiet) {
emitBare(categoryId ?? "");
return;
}
if (format === "text") {
emitBare(`created: ${categoryId ?? "-"} (${flags.name})`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,65 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_DELETE_FLAGS = {
categoryId: {
type: "string",
valueHint: "<id>",
description: "Category ID to delete",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Delete a data-center category",
auth: "apiKey",
usageArgs: "--category-id <id> [flags]",
flags: CATEGORY_DELETE_FLAGS,
notes: [
"Behavior for categories containing files or sub-categories is server-defined — the server error is passed through as-is.",
],
exampleArgs: ["--category-id cate-xxx --workspace-id ws-xxx", "--category-id cate-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { categoryId: flags.categoryId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteCategory);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
await confirmDangerousAction(
`Delete category ${flags.categoryId}\nThis cannot be undone.`,
flags.yes ?? false,
);
const response = await ctx.client.requestJson<
RagConnectorResponse<Record<string, unknown> | undefined>
>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${flags.categoryId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagListCategoryResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_LIST_FLAGS = {
collectionId: {
type: "string",
valueHint: "<id>",
description: "Filter by collection ID",
},
parentId: {
type: "string",
valueHint: "<id>",
description: "List sub-categories of this category",
},
name: {
type: "string",
valueHint: "<text>",
description: "Filter by category name (exact match, unlike the knowledge base list)",
},
nextToken: {
type: "string",
valueHint: "<token>",
description: "Cursor for the next page (from previous output)",
},
maxResult: {
type: "number",
valueHint: "<n>",
description: "Items per page (default: 20)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List data-center categories",
auth: "apiKey",
usageArgs: "[flags]",
flags: CATEGORY_LIST_FLAGS,
notes: [
"Categories marked [default] are where files land when no category is specified.",
"Pagination is cursor-based: reuse the printed next token to continue.",
],
exampleArgs: ["--workspace-id ws-xxx", "--name my-category", "--next-token <token>"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// type fixed to UNSTRUCTURED, not exposed as a flag (the only valid value today); note: maxResult is singular
const body = {
type: "UNSTRUCTURED",
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
...(flags.parentId ? { parentId: flags.parentId } : {}),
...(flags.name ? { categoryName: flags.name } : {}),
...(flags.nextToken ? { nextToken: flags.nextToken } : {}),
...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listCategory);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagListCategoryResponse>({
path: endpoint,
method: "POST",
body,
});
const categories = response.data?.categoryList ?? [];
if (settings.quiet) {
for (const category of categories) emitBare(category.categoryId ?? "");
return;
}
if (format === "text") {
if (categories.length === 0) {
emitBare("No categories found.");
} else {
for (const category of categories) {
const defaultMark = category.isDefault ? " [default]" : "";
emitBare(truncateLine(`${category.categoryId} ${category.categoryName}${defaultMark}`));
}
}
const nextToken = response.data?.nextToken;
if (nextToken) emitBare(`next: --next-token ${nextToken}`);
} else {
emitResult(response, format);
}
},
});
@@ -13,6 +13,7 @@ import {
type KnowledgeChatStreamChunk,
} from "bailian-cli-core";
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CHAT_FLAGS = {
message: {
@@ -27,11 +28,15 @@ const CHAT_FLAGS = {
description: "Q&A service ID (find in console knowledge Q&A page)",
required: true,
},
// 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。
workspaceId: {
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
...WORKSPACE_FLAG,
// Named to avoid the runtime-reserved global --version flag
agentVersion: {
type: "string",
valueHint: "<id>",
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
valueHint: "<version>",
description:
"Service version to call: beta (draft for debugging) or a published number; default is the latest published version",
},
image: {
type: "array",
@@ -146,6 +151,7 @@ export default defineCommand({
"Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.",
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id <id>`.",
'Multi-turn: use --message "user:..." and --message "assistant:..." to pass conversation history.',
"`--agent-version beta` calls the draft config for debugging before it is deployed.",
],
exampleArgs: [
'--message "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx',
@@ -168,14 +174,7 @@ export default defineCommand({
messages = [{ role: "user", content: "" }];
}
const workspaceId = flags.workspaceId || settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// API only supports SSE; streamOutput controls whether to print tokens in real-time
@@ -199,6 +198,9 @@ export default defineCommand({
parameters: {
agent_options: {
agent_id: flags.agentId,
// Omitted flag → field not sent (default behavior unchanged); the value is
// not validated — the set of versions is server-side state
...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}),
},
},
stream: true,
@@ -0,0 +1,160 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { readUtf8TextFile } from "./upload-support.ts";
const CHUNK_ADD_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
docId: {
type: "string",
valueHint: "<id>",
description: "Attach the chunk to this document (document-type knowledge bases)",
},
content: {
type: "string",
valueHint: "<text>",
description: "Chunk body text, up to 6000 chars (document-type); alternative to --content-file",
},
contentFile: {
type: "string",
valueHint: "<path>",
description: "Read chunk body from a UTF-8 plain text file (.md/.txt etc.)",
},
title: {
type: "string",
valueHint: "<text>",
description: "Chunk title, up to 50 chars (document-type)",
},
imageUrl: {
type: "array",
valueHint: "<url>",
description: "Chunk image URL (repeatable, up to 10; document-type)",
},
field: {
type: "array",
valueHint: "<key=value>",
description:
"Arbitrary field entry (repeatable) for table/image knowledge bases where keys are Excel column headers; mutually exclusive with content/title/image flags",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Parse --field key=value: split on the first =, value may contain = */
export function parseFieldEntries(entries: string[]): Record<string, string> {
const field: Record<string, string> = {};
for (const entry of entries) {
const separatorIndex = entry.indexOf("=");
if (separatorIndex <= 0) {
throw new BailianError(`--field must be key=value, got: ${entry}`, ExitCode.USAGE);
}
field[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
}
return field;
}
export default defineCommand({
description: "Add a chunk directly to a knowledge base",
auth: "apiKey",
usageArgs: "--index-id <id> (--content <text> | --field <k=v>) [flags]",
flags: CHUNK_ADD_FLAGS,
notes: [
"Document / table / image knowledge bases are supported; audio-video ones are not.",
"The API is idempotent but rate-limited to 10 calls per second — throttle batch scripts.",
"The response carries no chunk id; list chunks afterwards to find the new one.",
"For table/image knowledge bases use --field with Excel column headers as keys; values are passed through as strings.",
],
exampleArgs: [
'--index-id idx-xxx --content "chunk text" --title intro --workspace-id ws-xxx',
"--index-id idx-xxx --field 列A=v1 --field 列B=v2",
],
validate(flags) {
const hasConvenience =
flags.content !== undefined ||
flags.contentFile !== undefined ||
flags.title !== undefined ||
!!flags.imageUrl?.length;
const hasField = !!flags.field?.length;
if (hasConvenience && hasField) {
return "--field is mutually exclusive with --content/--content-file/--title/--image-url";
}
if (!hasConvenience && !hasField) {
return "Provide chunk content via --content/--content-file or --field entries";
}
if (flags.content !== undefined && flags.contentFile !== undefined) {
return "Use either --content or --content-file, not both";
}
if (flags.content !== undefined && flags.content.length > 6000) {
return "--content must be at most 6000 characters";
}
if (flags.title !== undefined && flags.title.length > 50) {
return "--title must be at most 50 characters";
}
if (flags.imageUrl !== undefined && flags.imageUrl.length > 10) {
return "--image-url accepts at most 10 entries";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// dry-run also reads the file and parses --field (rehearsal semantics)
let field: Record<string, unknown>;
if (flags.field?.length) {
field = parseFieldEntries(flags.field);
} else {
const content =
flags.contentFile !== undefined ? readUtf8TextFile(flags.contentFile) : flags.content;
if (typeof content === "string" && content.length > 6000) {
throw new BailianError("Chunk content must be at most 6000 characters", ExitCode.USAGE);
}
field = {
...(content !== undefined ? { content } : {}),
...(flags.title !== undefined ? { title: flags.title } : {}),
...(flags.imageUrl?.length ? { image_urls: flags.imageUrl } : {}),
};
}
const body = {
pipelineId: flags.indexId,
...(flags.docId ? { dataId: flags.docId } : {}),
field,
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkCreate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
// The response carries no chunk_id — quiet mode exits 0 silently on success
if (settings.quiet) return;
if (format === "text") {
emitBare(`chunk created (pipeline: ${flags.indexId})`);
emitBare("List chunks to find the new chunk id.");
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,108 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CHUNK_DELETE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
chunkId: {
type: "array",
valueHint: "<id>",
description: "Chunk ID to delete (repeatable; batches of 10 are sent automatically)",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** The server caps each request at 10 chunk ids — the client batches automatically (bulk delete is where the CLI beats the console) */
export function splitIntoBatches(chunkIds: string[], batchSize = 10): string[][] {
const batches: string[][] = [];
for (let batchStart = 0; batchStart < chunkIds.length; batchStart += batchSize) {
batches.push(chunkIds.slice(batchStart, batchStart + batchSize));
}
return batches;
}
export default defineCommand({
description: "Delete chunks from a knowledge base (irreversible)",
auth: "apiKey",
usageArgs: "--index-id <id> --chunk-id <id> [flags]",
flags: CHUNK_DELETE_FLAGS,
notes: [
"The server accepts at most 10 ids per call; larger sets are split into sequential batches automatically.",
"If a batch fails, the operation stops and already-deleted batches are listed in the error.",
],
exampleArgs: [
"--index-id idx-xxx --chunk-id chunk-a --chunk-id chunk-b --workspace-id ws-xxx",
"--index-id idx-xxx --chunk-id chunk-a --yes",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const batches = splitIntoBatches(flags.chunkId);
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkDelete);
if (settings.dryRun) {
emitResult(
{
endpoint,
batches: batches.map((batchIds) => ({
request: { pipelineId: flags.indexId, chunkIds: batchIds },
})),
},
format,
);
return;
}
await confirmDangerousAction(
`Delete ${flags.chunkId.length} chunk(s) from knowledge base ${flags.indexId} in ${batches.length} batch(es).\nChunks are permanently removed. This cannot be undone.`,
flags.yes ?? false,
);
// Sequential batches; any batch failure aborts, listing already-deleted batches in the error
let deletedCount = 0;
for (const batchIds of batches) {
try {
await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body: { pipelineId: flags.indexId, chunkIds: batchIds },
});
deletedCount += batchIds.length;
} catch (error) {
if (deletedCount > 0 && error instanceof BailianError && !error.hint) {
throw new BailianError(
error.message,
error.exitCode,
`${deletedCount} chunk(s) in earlier batches were already deleted.`,
{ cause: error, api: error.api, rawResponse: error.rawResponse },
);
}
throw error;
}
}
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${deletedCount} chunk(s) in ${batches.length} batch(es)`);
return;
}
emitResult({ deleted_count: deletedCount, batches: batches.length }, format);
},
});
@@ -0,0 +1,101 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagChunkListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const CHUNK_LIST_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
docId: {
type: "string",
valueHint: "<id>",
description: "Only show chunks belonging to this document",
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List chunks in a knowledge base with content and status",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: CHUNK_LIST_FLAGS,
notes: [
"Use metadata._id as the chunk id and metadata.doc_id as the document id in chunk update/delete commands.",
"Page size defaults to 20 (server default), max 100.",
],
exampleArgs: [
"--index-id idx-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --doc-id file-xxx --page-size 50",
],
validate(flags) {
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Gotcha: this endpoint's pagination keys are pageNum/pageSize (camelCase, in the body)
const body = {
indexId: flags.indexId,
pageNum: flags.pageNumber ?? 1,
pageSize: flags.pageSize ?? 20,
...(flags.docId ? { docId: flags.docId } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkList);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagChunkListResponse>({
path: endpoint,
method: "POST",
body,
});
const nodes = response.data?.nodes ?? [];
if (settings.quiet) {
// chunk ids only, for piping into chunk update/delete
for (const node of nodes) emitBare(node.metadata?._id ?? "");
return;
}
if (format === "text") {
if (nodes.length === 0) {
emitBare("No chunks found.");
} else {
for (const node of nodes) {
const metadata = node.metadata ?? {};
const statusPart = metadata._chunk_status_message
? ` status: ${metadata._chunk_status_message}`
: "";
const excludedPart =
metadata.is_displayed_chunk_content === false ? " [excluded from retrieval]" : "";
emitBare(
`[chunk] ${metadata._id ?? "?"} (doc: ${metadata.doc_name ?? "?"}, doc_id: ${metadata.doc_id ?? "?"})${statusPart}${excludedPart}`,
);
const contentText = metadata.content ?? node.text ?? "";
emitBare(` ${contentText.length > 200 ? `${contentText.slice(0, 200)}` : contentText}`);
}
}
emitBare(`total: ${response.data?.total ?? nodes.length}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,173 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type Client,
type FlagsDef,
type RagChunkListResponse,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { readUtf8TextFile } from "./upload-support.ts";
const CHUNK_UPDATE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
chunkId: {
type: "string",
valueHint: "<id>",
description: "Chunk ID (metadata._id from the chunk list output)",
required: true,
},
docId: {
type: "string",
valueHint: "<id>",
description: "Document ID owning the chunk (metadata.doc_id from the chunk list output)",
required: true,
},
content: {
type: "string",
valueHint: "<text>",
description: "New chunk content, 10-6000 chars; alternative to --content-file",
},
contentFile: {
type: "string",
valueHint: "<path>",
description: "Read new content from a UTF-8 plain text file (.md/.txt etc.)",
},
title: {
type: "string",
valueHint: "<text>",
description: "Chunk title, 0-50 chars (empty string clears it; omit to keep unchanged)",
},
exclude: { type: "switch", description: "Exclude this chunk from retrieval" },
include: { type: "switch", description: "Include this chunk in retrieval (default)" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** When only toggling include/exclude, read back the current content first (the API requires content — hide that quirk from users) */
async function fetchChunkContent(
client: Client,
workspaceId: string,
indexId: string,
chunkId: string,
docId: string,
): Promise<string> {
const maxPages = 10;
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
const response = await client.requestJson<RagChunkListResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.chunkList),
method: "POST",
body: { indexId, docId, pageNum, pageSize: 100 },
});
const nodes = response.data?.nodes ?? [];
const match = nodes.find((node) => node.metadata?._id === chunkId);
const matchContent = match?.metadata?.content ?? match?.text;
if (typeof matchContent === "string") return matchContent;
if (nodes.length < 100) break;
}
throw new BailianError(
`Chunk not found: ${chunkId}`,
ExitCode.GENERAL,
"Check the chunk id via the chunk list command.",
);
}
export default defineCommand({
description: "Update chunk content or toggle its retrieval visibility",
auth: "apiKey",
usageArgs: "--index-id <id> --chunk-id <id> --doc-id <id> [flags]",
flags: CHUNK_UPDATE_FLAGS,
notes: [
"Content must be 10-6000 characters and within the knowledge base's max chunk size.",
"--content-file expects a UTF-8 plain text file; document formats (.docx/.pdf) are not parsed here.",
"Toggling --exclude/--include without new content re-submits the existing content automatically.",
],
exampleArgs: [
'--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --content "corrected text"',
"--index-id idx-xxx --chunk-id chunk-xxx --doc-id file-xxx --exclude",
],
validate(flags) {
if (flags.content !== undefined && flags.contentFile !== undefined) {
return "Use either --content or --content-file, not both";
}
if (flags.exclude && flags.include) return "--exclude and --include are mutually exclusive";
const hasContent = flags.content !== undefined || flags.contentFile !== undefined;
if (!hasContent && !flags.exclude && !flags.include && flags.title === undefined) {
return "Nothing to update — pass --content/--content-file, --title, --exclude or --include";
}
// Content lower-bound is enforced here (not deferred to run) so dry-run and
// missing-flag diagnostics surface the same error as the live request.
if (flags.content !== undefined && (flags.content.length < 10 || flags.content.length > 6000)) {
return "--content must be 10-6000 characters";
}
if (flags.title !== undefined && flags.title.length > 50) {
return "--title must be at most 50 characters";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// dry-run also reads the file and validates (rehearsal semantics); the read-back
// request is only made outside dry-run and when no new content is given
let content =
flags.contentFile !== undefined ? readUtf8TextFile(flags.contentFile) : flags.content;
if (content !== undefined && (content.length < 10 || content.length > 6000)) {
throw new BailianError("Chunk content must be 10-6000 characters", ExitCode.USAGE);
}
if (content === undefined) {
if (settings.dryRun) {
content = "<current-content (fetched at run time)>";
} else {
content = await fetchChunkContent(
ctx.client,
workspaceId,
flags.indexId,
flags.chunkId,
flags.docId,
);
}
}
const body = {
pipelineId: flags.indexId,
chunkId: flags.chunkId,
dataId: flags.docId,
content,
// Without exclude/include the chunk stays retrievable (safe default)
isDisplayedChunkContent: !flags.exclude,
...(flags.title !== undefined ? { title: flags.title } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.chunkUpdate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`updated: ${flags.chunkId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,110 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAddConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const COLLECTION_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Collection name",
required: true,
},
description: {
type: "string",
valueHint: "<text>",
description: "Collection description (required by the server)",
required: true,
},
storeType: {
type: "string",
valueHint: "<type>",
description: "Storage: platform (managed) or custom (your own OSS bucket)",
},
ossRegion: {
type: "string",
valueHint: "<id>",
description: "OSS region id (required with --store-type custom)",
},
ossBucket: {
type: "string",
valueHint: "<name>",
description: "OSS bucket name (required with --store-type custom)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Create a FILE data collection",
auth: "apiKey",
usageArgs: "--name <text> --description <text> [flags]",
flags: COLLECTION_CREATE_FLAGS,
notes: [
"Store type defaults to platform (managed storage); custom uses your authorized OSS bucket.",
"There is no collection delete API — create collections deliberately.",
],
exampleArgs: [
"--name my-collection --description 'team docs' --workspace-id ws-xxx",
"--name oss-coll --description 'own bucket' --store-type custom --oss-region cn-beijing --oss-bucket my-bucket",
],
validate(flags) {
// Server rejects names longer than 20 characters ("Connector name is longer than 20")
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
const storeType = flags.storeType ?? "platform";
if (storeType !== "platform" && storeType !== "custom") {
return "--store-type must be platform or custom";
}
if (storeType === "custom" && (!flags.ossRegion || !flags.ossBucket)) {
return "--store-type custom requires --oss-region and --oss-bucket";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const storeType = (flags.storeType ?? "platform").toUpperCase();
// The server contract still uses connector* fields; only the CLI-facing term is collection
const body = {
connectorType: "FILE",
connectorName: flags.name,
description: flags.description,
fileConnectorConfig: {
storeType,
...(storeType === "CUSTOM"
? { ossRegionId: flags.ossRegion, ossBucket: flags.ossBucket }
: {}),
},
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addConnector);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAddConnectorResponse>({
path: endpoint,
method: "POST",
body,
});
const collectionId = response.data?.connectorId;
if (settings.quiet) {
emitBare(collectionId ?? "");
return;
}
if (format === "text") {
emitBare(`created: ${collectionId ?? "-"} (${flags.name}, ${storeType})`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,79 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagGetConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const COLLECTION_GET_FLAGS = {
collectionId: {
type: "string",
valueHint: "<id>",
description: "Collection ID; alternative to --name",
},
name: {
type: "string",
valueHint: "<text>",
description: "Collection name; alternative to --collection-id",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Show data collection details",
auth: "apiKey",
usageArgs: "(--collection-id <id> | --name <text>) [flags]",
flags: COLLECTION_GET_FLAGS,
exampleArgs: ["--collection-id conn-xxx --workspace-id ws-xxx", "--name my-collection"],
validate(flags) {
if (!flags.collectionId && !flags.name) return "Pass --collection-id or --name";
if (flags.collectionId && flags.name) return "Use either --collection-id or --name, not both";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// The server contract still uses connector* fields; only the CLI-facing term is collection
const body = {
...(flags.collectionId ? { connectorId: flags.collectionId } : {}),
...(flags.name ? { connectorName: flags.name } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.getConnector);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagGetConnectorResponse>({
path: endpoint,
method: "POST",
body,
});
const collection = response.data;
if (settings.quiet) {
emitBare(collection?.connectorId ?? "");
return;
}
if (format === "text") {
emitBare(`id: ${collection?.connectorId ?? "-"}`);
emitBare(`name: ${collection?.connectorName ?? "-"}`);
emitBare(`description: ${collection?.description ?? "-"}`);
const storeType = collection?.fileConnectorConfig?.storeType;
emitBare(`storeType: ${storeType ?? "-"}`);
if (storeType === "CUSTOM") {
emitBare(` ossRegion: ${collection?.fileConnectorConfig?.ossRegionId ?? "-"}`);
emitBare(` ossBucket: ${collection?.fileConnectorConfig?.ossBucket ?? "-"}`);
}
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,94 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagDeleteFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const DOC_DELETE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
docId: {
type: "array",
valueHint: "<id>",
description: "Document ID to delete (repeatable)",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */
function buildDeleteSummary(indexId: string, docIds: string[]): string {
const listed =
docIds.length <= 5
? docIds.join("\n ")
: `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`;
return `Delete ${docIds.length} document(s) from knowledge base ${indexId}:\n ${listed}\nDocuments and all their chunks are permanently removed from the index. This cannot be undone.`;
}
export default defineCommand({
description: "Delete documents and their chunks from a knowledge base",
auth: "apiKey",
usageArgs: "--index-id <id> --doc-id <id> [flags]",
flags: DOC_DELETE_FLAGS,
notes: [
"Removes documents from the knowledge base index only; the source files remain in the data center.",
"The output lists the ids actually deleted as reported by the server.",
],
exampleArgs: [
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --doc-id file-a --doc-id file-b --yes",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// snake_case: body { index_id, doc_ids }
const body = { index_id: flags.indexId, doc_ids: flags.docId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDeleteFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
await confirmDangerousAction(
buildDeleteSummary(flags.indexId, flags.docId),
flags.yes ?? false,
);
const response = await ctx.client.requestJson<RagDeleteFileResponse>({
path: endpoint,
method: "POST",
body,
});
// Output follows the server's data.deleted list
const deleted = response.data?.deleted ?? [];
if (settings.quiet) {
for (const docId of deleted) emitBare(docId);
return;
}
if (format === "text") {
emitBare(`deleted: ${deleted.length} document(s)`);
for (const docId of deleted) emitBare(` ${docId}`);
if (deleted.length !== flags.docId.length) {
process.stderr.write(
`Warning: requested ${flags.docId.length} deletion(s) but the server reported ${deleted.length}.\n`,
);
}
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,109 @@
import { basename } from "node:path";
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagOssImportResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const DOC_IMPORT_OSS_FLAGS = {
bucket: {
type: "string",
valueHint: "<name>",
description: "Authorized OSS bucket name",
required: true,
},
region: {
type: "string",
valueHint: "<id>",
description: "OSS region id (e.g. cn-beijing)",
required: true,
},
ossKey: {
type: "array",
valueHint: "<key>",
description: "OSS object key to import (repeatable, 1-10 per call)",
required: true,
},
categoryId: {
type: "string",
valueHint: "<id>",
description: "Target data-center category (default: the default category)",
},
tag: {
type: "array",
valueHint: "<text>",
description: "File tag applied to every imported file (repeatable, up to 10)",
},
overwrite: {
type: "switch",
description: "Overwrite files previously imported from the same OSS keys",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Batch import files from an authorized OSS bucket into the data center",
auth: "apiKey",
usageArgs: "--bucket <name> --region <id> --oss-key <key> [flags]",
flags: DOC_IMPORT_OSS_FLAGS,
notes: [
"The bucket must be authorized to the platform service role beforehand; permission errors from the server are passed through with a pointer to check AliyunServiceRoleForBailian in the RAM console.",
"File names are derived from the OSS key basename.",
],
exampleArgs: [
"--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --workspace-id ws-xxx",
"--bucket my-bucket --region cn-beijing --oss-key docs/a.pdf --oss-key docs/b.docx --overwrite",
],
validate(flags) {
if (flags.ossKey.length > 10) return "--oss-key accepts at most 10 entries per call";
if (flags.tag !== undefined && flags.tag.length > 10) {
return "--tag accepts at most 10 entries";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// categoryType fixed to UNSTRUCTURED; parser not exposed as a flag (defaults to AUTO_SELECT)
const body = {
categoryId: flags.categoryId ?? "default",
categoryType: "UNSTRUCTURED",
ossBucket: flags.bucket,
ossRegionId: flags.region,
fileDetails: flags.ossKey.map((ossKey) => ({ fileName: basename(ossKey), ossKey })),
...(flags.tag?.length ? { tags: flags.tag } : {}),
...(flags.overwrite ? { overWriteFileByOssKey: true } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.addFilesFromAuthorizedOss);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagOssImportResponse>({
path: endpoint,
method: "POST",
body,
});
const fileIds = response.data?.fileIds ?? [];
if (settings.quiet) {
for (const fileId of fileIds) emitBare(fileId);
return;
}
if (format === "text") {
emitBare(`imported: ${fileIds.length} file(s)`);
for (const fileId of fileIds) emitBare(` ${fileId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,83 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexFilesResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, ansi } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const DOC_LIST_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List documents in a knowledge base with parse/index status",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: DOC_LIST_FLAGS,
notes: [
"Documents with status FAILED are highlighted in text mode — use the import job status command to inspect failures.",
"Page size defaults to 10 (server default), max 100.",
],
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --page-size 100"],
validate(flags) {
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Gotcha: this endpoint's page parameter is page_num (not page_number)
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles));
url.searchParams.set("index_id", flags.indexId);
url.searchParams.set("page_num", String(flags.pageNumber ?? 1));
url.searchParams.set("page_size", String(flags.pageSize ?? 10));
const endpoint = url.toString();
if (settings.dryRun) {
emitResult({ endpoint, request: null }, format);
return;
}
const response = await ctx.client.requestJson<RagIndexFilesResponse>({
path: endpoint,
method: "GET",
});
const rows = response.data?.rows ?? [];
if (settings.quiet) {
for (const row of rows) emitBare(row.doc_id ?? "");
return;
}
if (format === "text") {
const styles = ansi(process.stdout);
if (rows.length === 0) {
emitBare("No documents found.");
} else {
for (const row of rows) {
const line = truncateLine(
[row.doc_id, row.status, row.doc_name, row.doc_type ?? "-", row.size ?? "-"].join(" "),
);
emitBare(row.status === "FAILED" ? styles.red(line) : line);
}
}
emitBare(`total: ${response.data?.total_count ?? rows.length}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,124 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagIndexJobStatusResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, ansi } from "bailian-cli-runtime";
import {
resolveWorkspaceId,
PAGE_FLAGS,
WORKSPACE_FLAG,
failedImportDocs,
importJobFailureMessage,
pollImportJob,
} from "./shared.ts";
const DOC_STATUS_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
jobId: {
type: "string",
valueHint: "<id>",
description: "Import job ID (ingestionId returned by import commands)",
required: true,
},
...PAGE_FLAGS,
wait: { type: "switch", description: "Poll until the job reaches a terminal state" },
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: "Polling interval when waiting (default: 5)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
function printStatus(response: RagIndexJobStatusResponse): void {
const styles = ansi(process.stdout);
emitBare(`status: ${response.data?.ingestion_status ?? "UNKNOWN"}`);
for (const doc of response.data?.rows ?? []) {
const docState = doc.code ?? doc.status ?? "?";
const line = ` ${doc.doc_id ?? "?"} ${docState} ${doc.doc_name ?? ""}`;
emitBare(docState.includes("FAILED") ? styles.red(line) : line);
}
}
export default defineCommand({
description: "Check knowledge base import job status",
auth: "apiKey",
usageArgs: "--index-id <id> --job-id <id> [flags]",
flags: DOC_STATUS_FLAGS,
notes: [
"Both --index-id and --job-id are required by the server (passing only one returns SystemError).",
"If the server returns SystemError for an idle knowledge base, the job may not exist — check the ingestion id in the document list output.",
"Overall job states are PENDING / RUNNING / COMPLETED; per-document failures (for example PARSE_FAILED) exit non-zero with the server message passed through.",
],
exampleArgs: [
"--index-id idx-xxx --job-id job-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --job-id job-xxx --wait --poll-interval 10",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Both required flags are enforced by the parser up front; parameters go in
// the query string (they are ignored in the body)
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus));
url.searchParams.set("index_id", flags.indexId);
url.searchParams.set("job_id", flags.jobId);
if (flags.pageNumber !== undefined) {
url.searchParams.set("page_number", String(flags.pageNumber));
}
if (flags.pageSize !== undefined) {
url.searchParams.set("page_size", String(flags.pageSize));
}
const endpoint = url.toString();
if (settings.dryRun) {
emitResult({ endpoint, request: null }, format);
return;
}
let response: RagIndexJobStatusResponse;
if (flags.wait) {
// Reuse the shared polling (timeout → TIMEOUT(5)); failure detection happens
// uniformly after return, based on per-document status
response = await pollImportJob(ctx.client, settings, {
statusUrl: endpoint,
intervalSec: flags.pollInterval ?? 5,
});
} else {
response = await ctx.client.requestJson<RagIndexJobStatusResponse>({
path: endpoint,
method: "GET",
});
}
// Any per-document failure means a non-zero exit; the server message is passed through verbatim
if (failedImportDocs(response).length > 0) {
throw new BailianError(
importJobFailureMessage(response, "Import job reported document failures."),
ExitCode.GENERAL,
);
}
if (settings.quiet) {
emitBare(response.data?.ingestion_status ?? "UNKNOWN");
return;
}
if (format === "text") {
printStatus(response);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,88 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagBatchUpdateTagResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const DOC_TAG_FLAGS = {
docId: {
type: "array",
valueHint: "<id>",
description: "Data-center file ID to tag (repeatable, 1-20 per call)",
required: true,
},
tag: {
type: "array",
valueHint: "<text>",
description: "Tag applied to every --doc-id (repeatable, each up to 32 chars)",
required: true,
},
mode: {
type: "string",
valueHint: "<mode>",
description: "Update mode: append (default) or overwrite",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Batch update tags on data-center files",
auth: "apiKey",
usageArgs: "--doc-id <id> --tag <text> [flags]",
flags: DOC_TAG_FLAGS,
notes: [
"The same tag set is applied to every --doc-id; run the command multiple times for different tag sets.",
"Server limits: up to 100 tags per file, total tag length up to 700 chars, tag up to 32 chars.",
],
exampleArgs: [
"--doc-id file-xxx --tag project-a --tag draft --workspace-id ws-xxx",
"--doc-id file-a --doc-id file-b --tag final --mode overwrite",
],
validate(flags) {
if (flags.docId.length > 20) return "--doc-id accepts at most 20 ids per call";
if (flags.mode !== undefined && flags.mode !== "append" && flags.mode !== "overwrite") {
return "--mode must be append or overwrite";
}
// Hard limits stated by the API contract: each tag ≤32 chars; ≤100 tags per file; total length ≤700
if (flags.tag.length > 100) return "At most 100 tags per file";
const overlongTag = flags.tag.find((tag) => tag.length > 32);
if (overlongTag) return `Tag exceeds 32 characters: ${overlongTag}`;
const totalLength = flags.tag.reduce((sum, tag) => sum + tag.length, 0);
if (totalLength > 700) return "Total tag length exceeds 700 characters";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
fileInfos: flags.docId.map((fileId) => ({ fileId, tags: flags.tag })),
updateMode: (flags.mode ?? "append").toUpperCase(),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.batchUpdateFileTag);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagBatchUpdateTagResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`tagged: ${flags.docId.length} file(s) with [${flags.tag.join(", ")}]`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,294 @@
// Orchestration command: local file → data center → (optional) import into a knowledge base.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagUploadLeaseResponse,
type RagAddFileResponse,
type RagJobCreateResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
resolveWorkspaceId,
WORKSPACE_FLAG,
failedImportDocs,
importJobFailureMessage,
importJobStatus,
importJobStatusUrl,
pollImportJob,
withPartialSuccessHint,
} from "./shared.ts";
import { checkUploadFile } from "./upload-support.ts";
const DOC_UPLOAD_FLAGS = {
file: {
type: "array",
valueHint: "<path>",
description: "Local file path (repeatable). Extension and size validated before upload",
required: true,
},
indexId: {
type: "string",
valueHint: "<id>",
description: "Import into this knowledge base after registration (one job for all files)",
},
categoryId: {
type: "string",
valueHint: "<id>",
description: "Target data-center category; defaults to the workspace default category",
},
tag: {
type: "array",
valueHint: "<text>",
description: "File tag (repeatable), applied to every uploaded file",
},
wait: {
type: "switch",
description: "Poll the import job to a terminal state (needs --index-id)",
},
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: "Polling interval when waiting (default: 5)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
interface UploadedFile {
path: string;
fileId: string;
}
export default defineCommand({
description: "Upload local files to the data center and optionally import into a knowledge base",
auth: "apiKey",
usageArgs: "--file <path> [flags]",
flags: DOC_UPLOAD_FLAGS,
notes: [
"Pipeline: apply upload lease → PUT to OSS → register file → (with --index-id) create import job.",
"Without --category-id the workspace default category is resolved automatically.",
"Multiple files are processed sequentially; on failure, already-registered file ids are listed in the error hint.",
],
exampleArgs: [
"--file ./a.md --workspace-id ws-xxx",
"--file ./a.md --file ./b.pdf --index-id idx-xxx --wait",
],
validate(flags) {
if (flags.wait && !flags.indexId) return "--wait requires --index-id";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Local pre-flight validation also runs in dry-run (rehearsal semantics: surface
// file problems early); exceeding a soft limit only warns
const checkedFiles = flags.file.map((filePath) => {
const checked = checkUploadFile(filePath);
if (checked.warning) process.stderr.write(`Warning: ${checked.warning}\n`);
return { filePath, sizeBytes: checked.sizeBytes };
});
if (settings.dryRun) {
// dry-run does not read file contents (md5 shown as a placeholder)
const categoryPlaceholder = flags.categoryId ?? "default";
const steps = checkedFiles.flatMap((checkedFile) => [
{
step: "applyFileUploadLease",
endpoint: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease),
request: {
category: categoryPlaceholder,
fileName: basename(checkedFile.filePath),
sizeBytes: String(checkedFile.sizeBytes), // gotcha: must be a string
contentMd5: "<md5-base64>",
} as unknown,
},
{
step: "ossPut",
endpoint: "<lease.param.url>",
request: { method: "PUT", headers: "<lease.param.headers>" } as unknown,
},
{
step: "addFile",
endpoint: ragEndpoint(workspaceId, RAG_PATHS.addFile),
request: {
leaseId: "<leaseId>",
category: categoryPlaceholder,
parser: "AUTO_SELECT",
...(flags.tag?.length ? { tags: flags.tag } : {}),
} as unknown,
},
]);
if (flags.indexId) {
steps.push({
step: "createImportJob",
endpoint: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate),
request: {
indexId: flags.indexId,
// Gotcha (live-verified): job/create requires the nested dataSource shape;
// the public docs' flat documentIds body returns Index.InvalidParameter.
// Omitting sourceType would import the entire data center.
dataSource: { sourceType: "DATA_CENTER_FILE", fileIds: ["<fileId>"] },
} as unknown,
});
}
emitResult({ steps }, format);
return;
}
// Default category: the literal "default" is accepted by lease/addFile
// (verified against the live API), so no listCategory resolution is needed
const categoryId = flags.categoryId ?? "default";
// Multiple files run steps 1-3 sequentially (no concurrency in this version,
// to avoid OSS rate-limit complexity)
const uploaded: UploadedFile[] = [];
for (const checkedFile of checkedFiles) {
try {
const fileBuffer = readFileSync(checkedFile.filePath);
const contentMd5 = createHash("md5").update(fileBuffer).digest("base64");
// 1) Apply for an upload lease (gotcha: the category parameter is named
// category, not categoryId; sizeBytes must be a string)
const lease = await ctx.client.requestJson<RagUploadLeaseResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.applyFileUploadLease),
method: "POST",
body: {
category: categoryId,
fileName: basename(checkedFile.filePath),
sizeBytes: String(checkedFile.sizeBytes),
contentMd5,
},
});
const leaseId = lease.data?.leaseId;
const leaseParam = lease.data?.param;
if (!leaseId || !leaseParam?.url) {
throw new BailianError(
`Upload lease response missing leaseId/url for ${checkedFile.filePath}`,
ExitCode.GENERAL,
);
}
// 2) OSS upload: goes to the OSS host, not the DashScope gateway — native fetch without a Bearer header
let ossResponse: Response;
try {
ossResponse = await fetch(leaseParam.url, {
method: leaseParam.method ?? "PUT",
headers: leaseParam.headers,
body: fileBuffer,
});
} catch (error) {
const causeCode = (error as { cause?: { code?: string } }).cause?.code;
throw new BailianError(
`OSS upload failed for ${basename(checkedFile.filePath)}`,
ExitCode.NETWORK,
causeCode ? `Network error (${causeCode}).` : undefined,
{ cause: error },
);
}
if (!ossResponse.ok) {
const ossBody = await ossResponse.text().catch(() => "");
throw new BailianError(
`OSS upload rejected (HTTP ${ossResponse.status}) for ${basename(checkedFile.filePath)}${ossBody ? `: ${ossBody.slice(0, 300)}` : ""}`,
ExitCode.GENERAL,
);
}
// 3) Register the file
const added = await ctx.client.requestJson<RagAddFileResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.addFile),
method: "POST",
body: {
leaseId,
category: categoryId,
parser: "AUTO_SELECT",
...(flags.tag?.length ? { tags: flags.tag } : {}),
},
});
const fileId = added.data?.fileId;
if (!fileId) {
throw new BailianError(
`addFile response missing fileId for ${checkedFile.filePath}`,
ExitCode.GENERAL,
);
}
uploaded.push({ path: checkedFile.filePath, fileId });
} catch (error) {
// Partial-failure semantics: abort with an error, listing already-registered
// fileIds in the hint (re-uploading is cheap and idempotent)
if (uploaded.length > 0) {
throw withPartialSuccessHint(
error,
`Already registered: ${uploaded.map((item) => item.fileId).join(", ")}`,
);
}
throw error;
}
}
// 4) Optional import (merged into a single job after all files are registered)
let ingestionId: string | undefined;
let finalStatus: string | undefined;
if (flags.indexId) {
const job = await ctx.client.requestJson<RagJobCreateResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.indexJobCreate),
method: "POST",
body: {
indexId: flags.indexId,
// Live-verified shape: nested dataSource (the docs' flat documentIds is rejected)
dataSource: {
sourceType: "DATA_CENTER_FILE",
fileIds: uploaded.map((item) => item.fileId),
},
},
});
ingestionId = job.data?.ingestionId;
if (flags.wait && ingestionId) {
const statusResponse = await pollImportJob(ctx.client, settings, {
statusUrl: importJobStatusUrl(workspaceId, flags.indexId, ingestionId).toString(),
intervalSec: flags.pollInterval ?? 5,
});
finalStatus = importJobStatus(statusResponse);
// Job finished but some documents failed to parse → non-zero exit, server message passed through verbatim
if (failedImportDocs(statusResponse).length > 0) {
throw new BailianError(
importJobFailureMessage(statusResponse, "Import job reported document failures."),
ExitCode.GENERAL,
`Registered file ids: ${uploaded.map((item) => item.fileId).join(", ")}`,
);
}
}
}
if (settings.quiet) {
for (const item of uploaded) emitBare(item.fileId);
return;
}
if (format === "text") {
for (const item of uploaded) {
emitBare(`${basename(item.path)} ${item.fileId} registered`);
}
if (ingestionId) emitBare(`job: ${ingestionId}`);
if (finalStatus) emitBare(`status: ${finalStatus}`);
return;
}
// An orchestration command has no single response to pass through — emit a custom stable shape
emitResult(
{
files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })),
...(flags.indexId ? { index_id: flags.indexId } : {}),
...(ingestionId ? { ingestion_id: ingestionId } : {}),
...(finalStatus ? { final_status: finalStatus } : {}),
},
format,
);
},
});
@@ -0,0 +1,88 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type Client,
type FlagsDef,
type RagConnectorResponse,
type RagDescribeFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const FILE_DELETE_FLAGS = {
fileId: {
type: "string",
valueHint: "<id>",
description: "Data-center file ID to delete",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup (file name/size); failure degrades to id-only */
async function buildDeleteSummary(
client: Client,
workspaceId: string,
fileId: string,
): Promise<string> {
let infoPart = "";
try {
const detail = await client.requestJson<RagDescribeFileResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.describeFile),
method: "POST",
body: { fileId },
});
if (detail.data?.fileName) infoPart = ` name: ${detail.data.fileName}`;
} catch {
// Degrade gracefully: a failed lookup does not block confirmation
}
return `Delete data-center file ${fileId}${infoPart}\nPERMANENT: if the file is referenced by knowledge bases, their document indexes break too. This differs from removing a document from one knowledge base.`;
}
export default defineCommand({
description: "Permanently delete a file from the data center",
auth: "apiKey",
usageArgs: "--file-id <id> [flags]",
flags: FILE_DELETE_FLAGS,
notes: [
"Irreversible. If knowledge bases reference this file, their related document indexes become invalid.",
"To remove a document from a single knowledge base only, use the document delete command instead.",
],
exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx", "--file-id file-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { fileId: flags.fileId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.deleteFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? ""
: await buildDeleteSummary(ctx.client, workspaceId, flags.fileId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<
RagConnectorResponse<Record<string, unknown> | undefined>
>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${flags.fileId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,63 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagDescribeFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const FILE_GET_FLAGS = {
fileId: {
type: "string",
valueHint: "<id>",
description: "Data-center file ID",
required: true,
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Show data-center file details (size, MD5, tags, timestamps)",
auth: "apiKey",
usageArgs: "--file-id <id> [flags]",
flags: FILE_GET_FLAGS,
exampleArgs: ["--file-id file-xxx --workspace-id ws-xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { fileId: flags.fileId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.describeFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagDescribeFileResponse>({
path: endpoint,
method: "POST",
body,
});
const file = response.data;
if (settings.quiet || format !== "text") {
emitResult(response, format === "text" ? "json" : format);
return;
}
emitBare(`id: ${file?.fileId ?? "-"}`);
emitBare(`name: ${file?.fileName ?? "-"}`);
emitBare(`type: ${file?.fileType ?? "-"}`);
emitBare(`size: ${file?.sizeBytes ?? "-"}`);
emitBare(`status: ${file?.status ?? "-"}`);
emitBare(`parser: ${file?.parser ?? "-"}`);
emitBare(`category: ${file?.category ?? "-"}`);
emitBare(`uploaded: ${file?.uploadTime ?? "-"}`);
const tags = Array.isArray(file?.tags) ? file.tags.join(", ") : (file?.tags ?? "-");
emitBare(`tags: ${tags || "-"}`);
},
});
@@ -0,0 +1,103 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagListFileResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, WORKSPACE_FLAG } from "./shared.ts";
const FILE_LIST_FLAGS = {
categoryId: {
type: "string",
valueHint: "<id>",
description: "Category to list (find ids via the category list command)",
required: true,
},
name: {
type: "string",
valueHint: "<text>",
description: "Filter by file name",
},
fileId: {
type: "array",
valueHint: "<id>",
description: "Filter by file ID (repeatable)",
},
nextToken: {
type: "string",
valueHint: "<token>",
description: "Cursor for the next page (from previous output)",
},
maxResult: {
type: "number",
valueHint: "<n>",
description: "Items per page",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List files in a data-center category",
auth: "apiKey",
usageArgs: "--category-id <id> [flags]",
flags: FILE_LIST_FLAGS,
notes: [
"The server requires a real category id here — unlike upload APIs, the literal default is NOT resolved (returns an empty list). Find the id via file details of any uploaded file, or the category list command.",
"Pagination is cursor-based: reuse the printed next token to continue.",
],
exampleArgs: [
"--category-id cate-xxx --workspace-id ws-xxx",
"--category-id cate-xxx --name report",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
categoryId: flags.categoryId,
...(flags.name ? { fileName: flags.name } : {}),
...(flags.fileId?.length ? { fileIds: flags.fileId } : {}),
...(flags.nextToken ? { nextToken: flags.nextToken } : {}),
...(flags.maxResult !== undefined ? { maxResult: flags.maxResult } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.listFile);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagListFileResponse>({
path: endpoint,
method: "POST",
body,
});
const files = response.data?.fileList ?? [];
if (settings.quiet) {
for (const file of files) emitBare(file.fileId ?? "");
return;
}
if (format === "text") {
if (files.length === 0) {
emitBare("No files found.");
} else {
for (const file of files) {
emitBare(
truncateLine(
[file.fileId, file.status ?? "-", file.fileName, file.sizeBytes ?? "-"].join(" "),
),
);
}
}
const nextToken = response.data?.nextToken;
if (nextToken) emitBare(`next: --next-token ${nextToken}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,165 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagCreateIndexV2Response,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import {
resolveWorkspaceId,
WORKSPACE_FLAG,
failedImportDocs,
importJobFailureMessage,
importJobStatus,
importJobStatusUrl,
pollImportJob,
} from "./shared.ts";
const KB_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Knowledge base name (1-20 chars, unique in workspace)",
required: true,
},
docId: {
type: "array",
valueHint: "<id>",
description:
"Data-center file id to import (repeatable); mutually exclusive with --category-id",
},
categoryId: {
type: "array",
valueHint: "<id>",
description:
"Import every file under this category (repeatable); mutually exclusive with --doc-id",
},
embeddingModel: {
type: "string",
valueHint: "<name>",
description: "Embedding model name (default: text-embedding-v4)",
},
chunkSize: {
type: "number",
valueHint: "<n>",
description: "Chunk size in characters (default: 600, recommended 300-800)",
},
wait: { type: "switch", description: "Poll the initial import job to a terminal state" },
pollInterval: {
type: "number",
valueHint: "<seconds>",
description: "Polling interval when waiting (default: 5)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** sourceType/docIds/categoryIds derivation, centralized for unit testing (gotcha: the parameter is docIds, not fileIds) */
export function buildDataSourceFields(flags: { docId?: string[]; categoryId?: string[] }): {
sourceType: string;
docIds?: string[];
categoryIds?: string[];
dataSources: Array<{ sourceType: string }>;
} {
if (flags.docId?.length) {
return {
sourceType: "DATA_CENTER_FILE",
docIds: flags.docId,
dataSources: [{ sourceType: "DATA_CENTER_FILE" }],
};
}
return {
sourceType: "DATA_CENTER_CATEGORY",
categoryIds: flags.categoryId,
dataSources: [{ sourceType: "DATA_CENTER_CATEGORY" }],
};
}
export default defineCommand({
description: "Create a knowledge base and import data-center files or categories",
auth: "apiKey",
usageArgs: "--name <text> (--doc-id <id> | --category-id <id>) [flags]",
flags: KB_CREATE_FLAGS,
notes: [
"Structure/sink types are fixed to the default document knowledge base (unstructured, BUILT_IN storage).",
"Returns the knowledge base id (pipelineId) and the initial import job id (ingestionId).",
"Use the import job status command (or --wait) to track the initial import.",
],
exampleArgs: [
"--name demo --doc-id file-xxx --workspace-id ws-xxx",
"--name demo --category-id cate-xxx --wait",
],
validate(flags) {
if (flags.name.length < 1 || flags.name.length > 20) return "--name must be 1-20 characters";
const hasDocIds = !!flags.docId?.length;
const hasCategoryIds = !!flags.categoryId?.length;
if (hasDocIds && hasCategoryIds) return "Use either --doc-id or --category-id, not both";
if (!hasDocIds && !hasCategoryIds)
return "Provide --doc-id or --category-id as the data source";
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Fixed values, not exposed as flags in this version: structureType unstructured, sinkType BUILT_IN.
// Note: the public docs' example uses sinkType DEFAULT, but BUILT_IN is what works against the live API.
const body = {
name: flags.name,
structureType: "unstructured",
sinkType: "BUILT_IN",
embeddingModelName: flags.embeddingModel ?? "text-embedding-v4",
chunkSize: flags.chunkSize ?? 600,
...buildDataSourceFields(flags),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexCreateV2);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagCreateIndexV2Response>({
path: endpoint,
method: "POST",
body,
});
const pipelineId = response.data?.pipelineId;
const ingestionId = response.data?.ingestionId;
let finalStatus: string | undefined;
if (flags.wait && pipelineId && ingestionId) {
const statusResponse = await pollImportJob(ctx.client, settings, {
statusUrl: importJobStatusUrl(workspaceId, pipelineId, ingestionId).toString(),
intervalSec: flags.pollInterval ?? 5,
});
finalStatus = importJobStatus(statusResponse);
// Job finished but some documents failed to parse → non-zero exit, server message
// passed through verbatim (the knowledge base was created; its id goes in the hint)
if (failedImportDocs(statusResponse).length > 0) {
throw new BailianError(
importJobFailureMessage(statusResponse, "Initial import reported document failures."),
ExitCode.GENERAL,
`Knowledge base created: ${pipelineId}`,
);
}
}
if (settings.quiet) {
emitBare(pipelineId ?? "");
return;
}
if (format === "text") {
emitBare(`index_id: ${pipelineId ?? "-"}`);
if (ingestionId) emitBare(`ingestion_id: ${ingestionId}`);
if (finalStatus) emitBare(`status: ${finalStatus}`);
emitBare("Next: check the import job status, then search against this knowledge base.");
return;
}
emitResult(finalStatus ? { ...response, final_status: finalStatus } : response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexFilesResponse,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { fetchIndexDetail } from "./kb-info.ts";
const KB_DELETE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup: name + document count; any lookup failure degrades to id-only (never blocks deletion) */
async function buildDeleteSummary(
ctx: { client: Parameters<typeof fetchIndexDetail>[0] },
workspaceId: string,
indexId: string,
): Promise<string> {
let namePart = "";
let docCountPart = "";
try {
const detail = await fetchIndexDetail(ctx.client, workspaceId, indexId);
namePart = ` name: ${detail.name}`;
} catch {
// Degrade gracefully: a missing name does not block confirmation
}
try {
const filesUrl = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexFiles));
filesUrl.searchParams.set("index_id", indexId);
filesUrl.searchParams.set("page_num", "1");
filesUrl.searchParams.set("page_size", "1");
const files = await ctx.client.requestJson<RagIndexFilesResponse>({
path: filesUrl.toString(),
method: "GET",
});
const totalCount = files.data?.total_count;
if (typeof totalCount === "number") docCountPart = ` documents: ${totalCount}`;
} catch {
// Same graceful degradation as above
}
return `Delete knowledge base ${indexId}${namePart}${docCountPart}\nThis permanently removes the knowledge base with all documents and chunks. It cannot be undone.`;
}
export default defineCommand({
description: "Delete a knowledge base with all its documents and chunks",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_DELETE_FLAGS,
notes: [
"Irreversible — the knowledge base and all indexed content are permanently removed.",
"Files in the data center are not affected; only the knowledge base index is deleted.",
],
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx", "--index-id idx-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// This endpoint is back to snake_case: body { index_id }
const body = { index_id: flags.indexId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexDelete);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? "" // --yes bypasses the prompt, so skip the summary lookups
: await buildDeleteSummary(ctx, workspaceId, flags.indexId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`deleted: ${flags.indexId}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,126 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type Client,
type FlagsDef,
type RagIndexListResponse,
type RagIndexRow,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const KB_INFO_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
function indexListUrl(workspaceId: string, pageNumber: number): string {
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList));
url.searchParams.set("page_number", String(pageNumber));
url.searchParams.set("page_size", "100");
return url.toString();
}
/**
* There is no dedicated detail API on the server, so fall back to paging
* through index/list. If a detail API ships, only this function changes.
* Also reused by kb delete for its confirmation summary.
*/
export async function fetchIndexDetail(
client: Client,
workspaceId: string,
indexId: string,
): Promise<RagIndexRow> {
const maxPages = 10;
for (let pageNumber = 1; pageNumber <= maxPages; pageNumber++) {
const response = await client.requestJson<RagIndexListResponse>({
path: indexListUrl(workspaceId, pageNumber),
method: "GET",
});
const rows = response.data?.rows ?? [];
const match = rows.find((row) => row.id === indexId);
if (match) return match;
if (rows.length < 100) break; // reached the last page
}
throw new BailianError(
`Knowledge base not found: ${indexId}`,
ExitCode.GENERAL,
"Check the id — list knowledge bases in this workspace to verify it.",
);
}
function formatField(label: string, value: string | number | boolean | null | undefined): string {
return ` ${label}: ${value ?? "-"}`;
}
export default defineCommand({
description: "Show knowledge base configuration details",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_INFO_FLAGS,
notes: [
"No dedicated detail API on the server yet — falls back to paginating the index list (up to 10 pages of 100).",
"Indexing settings are immutable; changing them requires recreating the knowledge base.",
],
exampleArgs: ["--index-id idx-xxx --workspace-id ws-xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
if (settings.dryRun) {
emitResult(
{
endpoint: indexListUrl(workspaceId, 1),
request: null,
strategy:
"paginate index/list (page_size=100, up to 10 pages) to find --index-id; no dedicated detail API",
},
format,
);
return;
}
const row = await fetchIndexDetail(ctx.client, workspaceId, flags.indexId);
if (settings.quiet || format !== "text") {
emitResult(row, format === "text" ? "json" : format);
return;
}
// Grouped by diagnostic concern; the immutable annotation on Indexing tells
// users which settings require recreating the knowledge base
emitBare("Basic:");
emitBare(formatField("id", row.id));
emitBare(formatField("name", row.name));
emitBare(formatField("description", row.description));
emitBare(formatField("dataType", row.dataType));
emitBare("Indexing: [immutable — recreate required to change]");
emitBare(formatField("embeddingModelName", row.embeddingModelName));
emitBare(formatField("embeddingDimension", row.embeddingDimension));
emitBare(formatField("chunkSize", row.chunkSize));
emitBare(formatField("overlapSize", row.overlapSize));
emitBare(formatField("chunkMode", row.chunkMode));
emitBare(formatField("separator", row.separator));
emitBare("Retrieval:");
emitBare(formatField("rerankModelName", row.rerankModelName));
emitBare(formatField("rerankMinScore", row.rerankMinScore));
emitBare(formatField("rerankTopN", row.rerankTopN));
emitBare(formatField("rerankMode", row.rerankMode));
emitBare(formatField("enableRewrite", row.enableRewrite));
emitBare(formatField("denseSimilarityTopK", row.denseSimilarityTopK));
emitBare(formatField("sparseSimilarityTopK", row.sparseSimilarityTopK));
emitBare("Data:");
emitBare(formatField("sourceType", row.sourceType));
emitBare(formatField("connectorId", row.connectorId));
},
});
@@ -0,0 +1,92 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const KB_LIST_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Filter by knowledge base name (fuzzy match, 1-20 chars)",
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "List knowledge bases in the workspace",
auth: "apiKey",
usageArgs: "[flags]",
flags: KB_LIST_FLAGS,
notes: [
"Auth: uses DashScope API Key (Bearer token).",
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or config workspace_id.",
"Use the returned id as --index-id in knowledge base / document management commands.",
],
exampleArgs: ["--workspace-id ws-xxx", "--name demo --page-number 2 --page-size 50"],
validate(flags) {
if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) {
return "--name must be 1-20 characters";
}
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Pagination and filter parameters must go in the query string — the server ignores them in the body
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexList));
if (flags.name) url.searchParams.set("pipeline_name", flags.name);
url.searchParams.set("page_number", String(flags.pageNumber ?? 1));
url.searchParams.set("page_size", String(flags.pageSize ?? 20));
const endpoint = url.toString();
if (settings.dryRun) {
emitResult({ endpoint, request: null }, format);
return;
}
const response = await ctx.client.requestJson<RagIndexListResponse>({
path: endpoint,
method: "GET",
});
const rows = response.data?.rows ?? [];
if (settings.quiet) {
for (const row of rows) emitBare(row.id);
return;
}
if (format === "text") {
if (rows.length === 0) {
emitBare("No knowledge bases found.");
} else {
for (const row of rows) {
emitBare(
truncateLine(
[
row.id,
row.name,
row.embeddingModelName ?? "-",
row.chunkSize ?? "-",
row.description ?? "",
].join(" "),
),
);
}
}
emitBare(`total: ${response.data?.total ?? rows.length}`);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,103 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type FlagsDef,
type RagMonitorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const KB_STATS_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
start: {
type: "string",
valueHint: "<time>",
description: "Range start: Unix seconds or ISO date (default: 24 hours ago)",
},
end: {
type: "string",
valueHint: "<time>",
description: "Range end: Unix seconds or ISO date (default: now)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Normalize time input to a second-precision string (the API requires seconds as a string). Accepts Unix seconds or an ISO date. */
export function toEpochSecondsString(input: string): string {
if (/^\d+$/.test(input)) {
// Digits-only input is treated as Unix seconds; 13-digit millisecond timestamps are reduced to seconds
return input.length >= 13 ? String(Math.floor(Number(input) / 1000)) : input;
}
const parsedMs = Date.parse(input);
if (Number.isNaN(parsedMs)) {
throw new BailianError(
`Invalid time value: ${input}`,
ExitCode.USAGE,
"Pass Unix seconds (e.g. 1780900000) or an ISO date (e.g. 2026-07-30T00:00:00Z).",
);
}
return String(Math.floor(parsedMs / 1000));
}
export default defineCommand({
description: "Show knowledge base storage and QPS monitoring data",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_STATS_FLAGS,
notes: [
"Defaults to the last 24 hours when --start/--end are omitted.",
"Timestamps are normalized to epoch seconds as required by the server.",
],
exampleArgs: [
"--index-id idx-xxx --workspace-id ws-xxx",
"--index-id idx-xxx --start 2026-07-30 --end 2026-07-31",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const nowSeconds = Math.floor(Date.now() / 1000);
const startTimestamp = flags.start
? toEpochSecondsString(flags.start)
: String(nowSeconds - 24 * 3600);
const endTimestamp = flags.end ? toEpochSecondsString(flags.end) : String(nowSeconds);
const body = { indexId: flags.indexId, startTimestamp, endTimestamp };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexMonitor);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMonitorResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet || format !== "text") {
emitResult(response, format === "text" ? "json" : format);
return;
}
// Shape verified against the live API: the monitor fields are objects, not arrays
const storage = response.data?.storageMonitorData;
const qps = response.data?.qpsMonitorData;
emitBare(`plan: ${response.data?.pipelineCommercialType ?? "-"}`);
emitBare(
`storage: ${storage?.indexStorageUsage ?? "-"} / ${storage?.indexStorageLimit ?? "-"}`,
);
emitBare(`peak qps: ${qps?.peakQps ?? "-"}`);
emitBare(`qps windows: ${qps?.monitorData?.length ?? 0} data point(s)`);
},
});
@@ -0,0 +1,100 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const KB_UPDATE_FLAGS = {
indexId: {
type: "string",
valueHint: "<id>",
description: "Knowledge base ID",
required: true,
},
name: {
type: "string",
valueHint: "<text>",
description: "New knowledge base name (1-20 chars)",
},
description: {
type: "string",
valueHint: "<text>",
description: "New knowledge base description",
},
rerankMinScore: {
type: "number",
valueHint: "<score>",
description: "Rerank minimum score threshold, range 0-1 (chunks below are filtered)",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Update knowledge base name, description or rerank threshold",
auth: "apiKey",
usageArgs: "--index-id <id> [flags]",
flags: KB_UPDATE_FLAGS,
notes: [
"Indexing settings (embedding model, chunk size, etc.) are immutable — recreate the knowledge base to change them.",
],
exampleArgs: [
"--index-id idx-xxx --description 'product docs v2' --workspace-id ws-xxx",
"--index-id idx-xxx --rerank-min-score 0.3",
],
validate(flags) {
if (
flags.name === undefined &&
flags.description === undefined &&
flags.rerankMinScore === undefined
) {
return "Nothing to update — pass --name, --description or --rerank-min-score";
}
if (flags.name !== undefined && (flags.name.length < 1 || flags.name.length > 20)) {
return "--name must be 1-20 characters";
}
if (
flags.rerankMinScore !== undefined &&
(flags.rerankMinScore < 0 || flags.rerankMinScore > 1)
) {
return "--rerank-min-score must be between 0 and 1";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// Gotcha: this endpoint names the knowledge base ID parameter `id` (not index_id/indexId)
const body = {
id: flags.indexId,
...(flags.name !== undefined ? { name: flags.name } : {}),
...(flags.description !== undefined ? { description: flags.description } : {}),
...(flags.rerankMinScore !== undefined ? { rerankMinScore: flags.rerankMinScore } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.indexUpdate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`updated: ${flags.indexId}`);
return;
}
emitResult(response, format);
},
});
@@ -9,6 +9,7 @@ import {
type KnowledgeSearchResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SEARCH_FLAGS = {
query: {
@@ -23,11 +24,15 @@ const SEARCH_FLAGS = {
description: "Retrieval service ID (find in console knowledge retrieval page)",
required: true,
},
// 知识库走 workspace 专属域名,--workspace-id 属命令自有 flag(console 凭证域不适用)。
workspaceId: {
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
...WORKSPACE_FLAG,
// Named to avoid the runtime-reserved global --version flag
agentVersion: {
type: "string",
valueHint: "<id>",
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
valueHint: "<version>",
description:
"Service version to call: beta (draft for debugging) or a published number; default is the latest published version",
},
image: {
type: "array",
@@ -52,6 +57,7 @@ export default defineCommand({
"Auth: uses DashScope API Key (Bearer token). Get yours from the console API Key page.",
"`--workspace-id` can be set via BAILIAN_WORKSPACE_ID env or `kscli config set workspace_id <id>`.",
"`--query-history` passes prior conversation turns; the server rewrites the query based on context to improve retrieval relevance.",
"`--agent-version beta` calls the draft config for debugging before it is deployed.",
],
exampleArgs: [
'--query "What is RAG?" --agent-id aid-xxx --workspace-id ws-xxx',
@@ -61,14 +67,7 @@ export default defineCommand({
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = flags.workspaceId || settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
@@ -77,6 +76,12 @@ export default defineCommand({
agent_id: flags.agentId,
};
// Omitted flag → field not sent (default behavior unchanged: latest published
// version); the value is not validated — the set of versions is server-side state
if (flags.agentVersion) {
body.agent_version = flags.agentVersion;
}
if (flags.image && flags.image.length > 0) {
body.images = flags.image;
}
@@ -0,0 +1,67 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_COPY_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Source service (agent) ID to copy",
required: true,
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Copy a service into a new draft (name gets a copy_ prefix)",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_COPY_FLAGS,
notes: [
"The copy starts as a beta draft; test it with --agent-version beta, then deploy to publish.",
"Requires the knowledge-base create permission in the workspace.",
],
exampleArgs: ["--agent-id aid-xxx --workspace-id ws-xxx"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { agent_id: flags.agentId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCopy);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
const newAgentId = agentMutationField(response, "agent_id");
if (settings.quiet) {
emitBare(newAgentId ?? "");
return;
}
if (format === "text") {
emitBare(
`new agent_id: ${newAgentId ?? "-"} (name: ${agentMutationField(response, "agent_name") ?? "-"}, status: ${agentMutationField(response, "agent_status") ?? "draft"})`,
);
emitBare(
"Test the draft with --agent-version beta on search/chat, then deploy it to publish.",
);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,105 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_CREATE_FLAGS = {
name: {
type: "string",
valueHint: "<text>",
description: "Service name (up to 200 chars, unique per scene in the workspace)",
required: true,
},
scene: {
type: "string",
valueHint: "<scene>",
description: "Service scene: chat (Q&A) or search (retrieval)",
required: true,
},
description: {
type: "string",
valueHint: "<text>",
description: "Service description (up to 1000 chars)",
},
indexId: {
type: "string",
valueHint: "<id>",
description: "Bind this knowledge base; other settings use server defaults",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: "Create a retrieval / Q&A service (initial status: draft, version: beta)",
auth: "apiKey",
usageArgs: "--name <text> --scene <chat|search> [flags]",
flags: SERVICE_CREATE_FLAGS,
notes: [
"Without an explicit configuration the server applies its default agent settings.",
"The draft (beta) version can be tested via --agent-version beta on search/chat before deploying.",
"Requires the knowledge-base create permission in the workspace.",
],
exampleArgs: [
"--name my-qa --scene chat --workspace-id ws-xxx",
"--name my-search --scene search --index-id idx-xxx",
],
validate(flags) {
if (flags.name.length > 200) return "--name must be at most 200 characters";
if (flags.scene !== "chat" && flags.scene !== "search") {
return "--scene must be chat or search";
}
if (flags.description !== undefined && flags.description.length > 1000) {
return "--description must be at most 1000 characters";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// agent_config is optional — omit to use server defaults; with --index-id build
// the minimal kb_search_configs (name/desc etc. are backfilled by the system from
// the knowledge base, so they are not sent)
const body = {
agent_name: flags.name,
agent_scene: flags.scene,
...(flags.description ? { agent_desc: flags.description } : {}),
...(flags.indexId ? { agent_config: { kb_search_configs: [{ id: flags.indexId }] } } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentCreate);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
const agentId = agentMutationField(response, "agent_id");
if (settings.quiet) {
emitBare(agentId ?? "");
return;
}
if (format === "text") {
emitBare(
`created: ${agentId ?? "-"} (status: ${agentMutationField(response, "agent_status") ?? "draft"}, version: ${agentMutationField(response, "agent_version") ?? "beta"})`,
);
emitBare(
"Test the draft with --agent-version beta on search/chat, then deploy it to publish.",
);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,98 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type Client,
type FlagsDef,
type RagAgentGetResponse,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_DELETE_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup (name/status); failure degrades to id-only */
async function buildDeleteSummary(
client: Client,
workspaceId: string,
agentId: string,
): Promise<string> {
let infoPart = "";
let liveWarning = "";
try {
const detail = await client.requestJson<RagAgentGetResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
method: "POST",
body: { agent_id: agentId },
});
const name = detail.data?.agent_name;
const status = detail.data?.agent_status;
if (name) infoPart += ` name: ${name}`;
if (status) {
infoPart += ` status: ${status}`;
if (status === "deployed" || status === "edited") {
liveWarning = "\nWARNING: this service is LIVE — deleting it breaks existing callers.";
}
}
} catch {
// Degrade gracefully: a failed lookup does not block confirmation
}
return `Delete service ${agentId}${infoPart}${liveWarning}\nDeletion cannot be undone; the agent_id can no longer be used for search or chat calls.`;
}
export default defineCommand({
description: "Delete a retrieval / Q&A service (soft delete, idempotent)",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_DELETE_FLAGS,
notes: [
"Deletion cannot be undone; the agent_id becomes unusable for search and chat calls.",
"The API is idempotent — deleting an already-deleted service does not fail.",
"Requires the knowledge-base delete permission in the workspace.",
],
exampleArgs: ["--agent-id aid-xxx --workspace-id ws-xxx", "--agent-id aid-xxx --yes"],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = { agent_id: flags.agentId };
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDelete);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? ""
: await buildDeleteSummary(ctx.client, workspaceId, flags.agentId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(
`deleted: ${flags.agentId} (status: ${agentMutationField(response, "agent_status") ?? "deleted"})`,
);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,112 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type Client,
type FlagsDef,
type RagAgentGetResponse,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_DEPLOY_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
versionDesc: {
type: "string",
valueHint: "<text>",
description: "Description for the newly published version",
},
yes: { type: "switch", description: "Skip the confirmation prompt" },
...WORKSPACE_FLAG,
} satisfies FlagsDef;
/** Confirmation summary lookup (name/status); warns that deploying an edited draft overwrites live behavior; failure degrades to id-only */
async function buildDeploySummary(
client: Client,
workspaceId: string,
agentId: string,
): Promise<string> {
let infoPart = "";
let editedWarning = "";
try {
const detail = await client.requestJson<RagAgentGetResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
method: "POST",
body: { agent_id: agentId },
});
const name = detail.data?.agent_name;
const status = detail.data?.agent_status;
if (name) infoPart += ` name: ${name}`;
if (status) {
infoPart += ` status: ${status}`;
if (status === "edited") {
editedWarning =
"\nWARNING: a published version is live — deploying replaces its behavior with the current draft.";
}
}
} catch {
// Degrade gracefully: a failed lookup does not block confirmation
}
return `Deploy service ${agentId}${infoPart}${editedWarning}\nPublishing changes what live callers get from this service.`;
}
export default defineCommand({
description: "Publish the beta draft of a service as a new version",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_DEPLOY_FLAGS,
notes: [
"The version number auto-increments; status becomes deployed.",
"Publishing affects live callers — the confirmation prompt guards against accidents.",
"Requires the knowledge-base modify permission in the workspace.",
],
exampleArgs: [
"--agent-id aid-xxx --workspace-id ws-xxx",
"--agent-id aid-xxx --version-desc 'tuned rerank params' --yes",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
agent_id: flags.agentId,
...(flags.versionDesc ? { agent_version_desc: flags.versionDesc } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentDeploy);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const summary = flags.yes
? ""
: await buildDeploySummary(ctx.client, workspaceId, flags.agentId);
await confirmDangerousAction(summary, flags.yes ?? false);
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
const newVersion = agentMutationField(response, "agent_version");
if (settings.quiet) {
emitBare(newVersion ?? "");
return;
}
if (format === "text") {
emitBare(`deployed: ${flags.agentId} version ${newVersion ?? "-"}`);
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,94 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentDetail,
type RagAgentGetResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_GET_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
agentVersion: {
type: "string",
valueHint: "<version>",
description: "Specific version to inspect (beta or a published number); omit for all versions",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
function printDetail(detail: RagAgentDetail): void {
emitBare(`Version ${detail.agent_version ?? "?"}:`);
if (detail.agent_version_desc) emitBare(` desc: ${detail.agent_version_desc}`);
if (detail.publish_time !== undefined) emitBare(` published: ${detail.publish_time}`);
const config = detail.agent_config;
if (!config) return;
if (config.agent_policy) emitBare(` policy: ${config.agent_policy}`);
if (config.agent_model) emitBare(` model: ${config.agent_model}`);
if (config.temperature !== undefined) emitBare(` temperature: ${config.temperature}`);
for (const kbConfig of config.kb_search_configs ?? []) {
const kbId = typeof kbConfig.id === "string" ? kbConfig.id : "?";
const kbName = typeof kbConfig.name === "string" ? ` (${kbConfig.name})` : "";
emitBare(` kb: ${kbId}${kbName}`);
}
}
export default defineCommand({
description: "Show service (agent) details including per-version configuration",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_GET_FLAGS,
notes: [
"Without --agent-version all versions are returned (beta draft plus published numbers).",
"The version value is passed through as-is; the valid set is server-side state.",
],
exampleArgs: [
"--agent-id aid-xxx --workspace-id ws-xxx",
"--agent-id aid-xxx --agent-version beta",
],
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const body = {
agent_id: flags.agentId,
...(flags.agentVersion ? { agent_version: flags.agentVersion } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentGet);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentGetResponse>({
path: endpoint,
method: "POST",
body,
});
const data = response.data;
if (settings.quiet || format !== "text") {
emitResult(response, format === "text" ? "json" : format);
return;
}
emitBare("Basic:");
emitBare(` id: ${data?.agent_id ?? "-"}`);
emitBare(` name: ${data?.agent_name ?? "-"}`);
emitBare(` desc: ${data?.agent_desc ?? "-"}`);
emitBare(` scene: ${data?.agent_scene ?? "-"}`);
emitBare(` status: ${data?.agent_status ?? "-"}`);
for (const detail of data?.agent_details ?? []) {
printDetail(detail);
}
},
});
@@ -0,0 +1,134 @@
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagAgentListResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, truncateLine, PAGE_FLAGS, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_LIST_FLAGS = {
scene: {
type: "string",
valueHint: "<scene>",
description: "Service scene: chat (Q&A) or search (retrieval). Required by the server",
required: true,
},
status: {
type: "string",
valueHint: "<status>",
description: "Filter by status: draft, deployed (includes edited) or deleted",
},
name: {
type: "string",
valueHint: "<text>",
description: "Filter by service name (fuzzy match)",
},
agentId: {
type: "string",
valueHint: "<id>",
description: "Filter by exact agent ID",
},
indexId: {
type: "string",
valueHint: "<id>",
description: "Filter by linked knowledge base (pipeline) ID",
},
...PAGE_FLAGS,
...WORKSPACE_FLAG,
} satisfies FlagsDef;
const SCENES = ["chat", "search"];
const STATUSES = ["draft", "deployed", "deleted"];
export default defineCommand({
description: "List retrieval / Q&A services (agents) in the workspace",
auth: "apiKey",
usageArgs: "--scene <chat|search> [flags]",
flags: SERVICE_LIST_FLAGS,
notes: [
"The server requires a scene — run once per scene to see both chat and search services.",
"Use the returned agent_id with the search or chat commands, or with service management commands.",
],
exampleArgs: ["--scene chat --workspace-id ws-xxx", "--scene search --status deployed"],
validate(flags) {
if (!SCENES.includes(flags.scene)) return "--scene must be chat or search";
if (flags.status !== undefined && !STATUSES.includes(flags.status)) {
return "--status must be draft, deployed or deleted";
}
if (flags.pageSize !== undefined && (flags.pageSize < 1 || flags.pageSize > 100)) {
return "--page-size must be between 1 and 100";
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
// agent/list pagination goes in the body
const body = {
agent_scene: flags.scene,
...(flags.status ? { agent_status: flags.status } : {}),
...(flags.name ? { agent_name: flags.name } : {}),
...(flags.agentId ? { agent_id: flags.agentId } : {}),
...(flags.indexId ? { pipeline_id: flags.indexId } : {}),
page_number: flags.pageNumber ?? 1,
page_size: flags.pageSize ?? 10,
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentList);
if (settings.dryRun) {
emitResult({ endpoint, request: body }, format);
return;
}
const response = await ctx.client.requestJson<RagAgentListResponse>({
path: endpoint,
method: "POST",
body,
});
const rows = response.data?.rows ?? [];
if (settings.quiet) {
for (const row of rows) emitBare(row.agent_id ?? "");
return;
}
if (format === "text") {
if (rows.length === 0) {
emitBare("No services found.");
} else {
for (const row of rows) {
const kbNames = (row.pipeline_list ?? [])
.map((pipeline) => pipeline.pipeline_name)
.filter(Boolean)
.join(",");
emitBare(
truncateLine(
[
row.agent_id,
row.agent_status,
row.agent_version,
row.agent_name,
kbNames ? `(kb: ${kbNames})` : "",
]
.filter(Boolean)
.join(" "),
),
);
}
}
emitBare(`total: ${response.data?.total_count ?? rows.length}`);
// Connect finding an ID with using it (capability wording, no hardcoded product path)
emitBare(
flags.scene === "search"
? "Use an agent_id above with the knowledge search command."
: "Use an agent_id above with the knowledge chat command.",
);
} else {
emitResult(response, format);
}
},
});
@@ -0,0 +1,362 @@
import { readFileSync } from "node:fs";
import {
defineCommand,
ragEndpoint,
RAG_PATHS,
detectOutputFormat,
BailianError,
ExitCode,
type Client,
type FlagsDef,
type RagAgentConfig,
type RagAgentGetResponse,
type RagAgentMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const BOOL_CHOICES = ["true", "false"];
const SERVICE_UPDATE_FLAGS = {
agentId: {
type: "string",
valueHint: "<id>",
description: "Service (agent) ID",
required: true,
},
name: {
type: "string",
valueHint: "<text>",
description: "New service name (up to 200 chars)",
},
description: {
type: "string",
valueHint: "<text>",
description: "New service description (up to 1000 chars)",
},
agentVersion: {
type: "string",
valueHint: "<version>",
description:
"Target version (default: beta draft). Published versions only accept --version-desc",
},
versionDesc: {
type: "string",
valueHint: "<text>",
description: "Version description",
},
policy: {
type: "string",
valueHint: "<policy>",
description: "Agent policy: turbo (fast) or agentic (multi-turn)",
},
model: {
type: "string",
valueHint: "<name>",
description: "Generation model code (must be in the platform allowlist)",
},
temperature: {
type: "number",
valueHint: "<n>",
description: "Sampling temperature, range 0-2",
},
maxLlmCalls: {
type: "number",
valueHint: "<n>",
description: "Max LLM calls per request, range 1-30",
},
enableSessionFile: {
type: "string",
valueHint: "<bool>",
description: "Enable session files: true or false",
},
enableRefusal: {
type: "string",
valueHint: "<bool>",
description: "Enable refusal answers: true or false",
},
enableAntiLeak: {
type: "string",
valueHint: "<bool>",
description: "Enable anti prompt-leak: true or false",
},
enableRichText: {
type: "string",
valueHint: "<bool>",
description: "Enable rich text output: true or false",
},
enableCitation: {
type: "string",
valueHint: "<bool>",
description: "Enable citations: true or false",
},
configFile: {
type: "string",
valueHint: "<path>",
description:
"JSON file replacing the whole agent_config (for nested settings like kb_search_configs); mutually exclusive with scalar config flags",
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
type UpdateFlags = {
policy?: string;
model?: string;
temperature?: number;
maxLlmCalls?: number;
enableSessionFile?: string;
enableRefusal?: string;
enableAntiLeak?: string;
enableRichText?: string;
enableCitation?: string;
};
/** Scalar config flag → agent_config field mapping (centralized for validation and merge) */
function collectScalarConfig(flags: UpdateFlags): Partial<RagAgentConfig> {
const scalar: Partial<RagAgentConfig> = {};
if (flags.policy !== undefined) scalar.agent_policy = flags.policy;
if (flags.model !== undefined) scalar.agent_model = flags.model;
if (flags.temperature !== undefined) scalar.temperature = flags.temperature;
if (flags.maxLlmCalls !== undefined) scalar.max_num_llm_calls = flags.maxLlmCalls;
if (flags.enableSessionFile !== undefined) scalar.enable_session_file = flags.enableSessionFile;
if (flags.enableRefusal !== undefined) scalar.enable_refusal = flags.enableRefusal;
if (flags.enableAntiLeak !== undefined) scalar.enable_anti_leak = flags.enableAntiLeak;
if (flags.enableRichText !== undefined) scalar.enable_rich_text = flags.enableRichText;
if (flags.enableCitation !== undefined) scalar.enable_citation = flags.enableCitation;
return scalar;
}
/** --config-file: read JSON + structural/enum/range validation; unknown fields warn but pass through */
export function parseConfigFile(filePath: string): RagAgentConfig {
let raw: string;
try {
raw = readFileSync(filePath, "utf-8");
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read config file: ${filePath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new BailianError("--config-file must contain valid JSON.", ExitCode.USAGE);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new BailianError("--config-file must contain a JSON object.", ExitCode.USAGE);
}
const config = parsed as RagAgentConfig;
validateConfigValues(config);
return config;
}
const KNOWN_CONFIG_KEYS = new Set([
"agent_policy",
"agent_model",
"enable_session_file",
"enable_refusal",
"enable_anti_leak",
"enable_rich_text",
"enable_citation",
"temperature",
"max_num_llm_calls",
"max_completion_tokens",
"session_file_max_parse_length",
"enable_kb_router",
"kb_router_model",
"rerank_top_n",
"hybrid_rerank",
"kb_search_configs",
]);
/** Enum/range validation + behavioral warnings, per the agent_config contract */
export function validateConfigValues(config: RagAgentConfig): void {
if (config.agent_policy !== undefined && !["turbo", "agentic"].includes(config.agent_policy)) {
throw new BailianError("agent_policy must be turbo or agentic", ExitCode.USAGE);
}
if (config.temperature !== undefined && (config.temperature < 0 || config.temperature > 2)) {
throw new BailianError("temperature must be between 0 and 2", ExitCode.USAGE);
}
if (
config.max_num_llm_calls !== undefined &&
(config.max_num_llm_calls < 1 || config.max_num_llm_calls > 30)
) {
throw new BailianError("max_num_llm_calls must be between 1 and 30", ExitCode.USAGE);
}
if (config.rerank_top_n !== undefined && (config.rerank_top_n < 1 || config.rerank_top_n > 20)) {
throw new BailianError("rerank_top_n must be between 1 and 20", ExitCode.USAGE);
}
// Behavioral warning: the server clears rerank_instruct unless rerank_mode is custom
for (const kbConfig of config.kb_search_configs ?? []) {
const rerank = kbConfig.rerank as
| { rerank_mode?: string; rerank_instruct?: string }
| undefined;
if (rerank?.rerank_instruct && rerank.rerank_mode !== "custom") {
process.stderr.write(
"Warning: rerank_instruct is only effective with rerank_mode=custom; the server clears it otherwise.\n",
);
}
}
// Unknown fields: warn but pass through (the server is the source of truth)
for (const key of Object.keys(config)) {
if (!KNOWN_CONFIG_KEYS.has(key)) {
process.stderr.write(`Warning: unknown agent_config field passed through: ${key}\n`);
}
}
}
/** read-merge-write: fetch the full beta-draft config and merge scalar changes (the API has whole-replace semantics) */
async function fetchBetaConfig(
client: Client,
workspaceId: string,
agentId: string,
): Promise<RagAgentConfig> {
const response = await client.requestJson<RagAgentGetResponse>({
path: ragEndpoint(workspaceId, RAG_PATHS.agentGet),
method: "POST",
body: { agent_id: agentId, agent_version: "beta" },
});
const betaDetail = (response.data?.agent_details ?? []).find(
(detail) => detail.agent_version === "beta",
);
return betaDetail?.agent_config ?? {};
}
export default defineCommand({
description: "Update service name, description or draft configuration",
auth: "apiKey",
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_UPDATE_FLAGS,
notes: [
"Configuration changes only apply to the beta draft; published versions accept --version-desc only.",
"To change the configuration of a published version, first update the beta draft (this command without --agent-version or with --agent-version beta), then run service deploy to publish a new version.",
"Scalar config flags merge into the current draft config (read-merge-write); --config-file replaces the whole config and is mutually exclusive with them.",
"After updating the draft, verify with --agent-version beta on search/chat, then deploy.",
"Requires the knowledge-base modify permission in the workspace.",
],
// Note on `agent_version` in the request body: this is the agent-management
// domain's "target version" parameter (beta draft or a published version
// number). It is NOT the search/chat debug-draft field that the project-wide
// agent_version cleanup removes — the two share a name but live in different
// API surfaces and have different semantics.
exampleArgs: [
"--agent-id aid-xxx --temperature 0.7 --workspace-id ws-xxx",
"--agent-id aid-xxx --config-file ./agent-config.json",
"--agent-id aid-xxx --agent-version 1 --version-desc 'first stable release'",
],
validate(flags) {
const scalarTouched = Object.keys(collectScalarConfig(flags)).length > 0;
const anyChange =
flags.name !== undefined ||
flags.description !== undefined ||
flags.versionDesc !== undefined ||
flags.configFile !== undefined ||
scalarTouched;
if (!anyChange)
return "Nothing to update — pass a name/description/version-desc or config flags";
if (flags.configFile !== undefined && scalarTouched) {
return "--config-file is mutually exclusive with scalar config flags";
}
// Version check up front: published version + config change → USAGE (the server
// would also reject it, but failing early is faster)
const publishedVersion = flags.agentVersion !== undefined && flags.agentVersion !== "beta";
if (publishedVersion && (scalarTouched || flags.configFile !== undefined)) {
return "Published versions only accept --version-desc; update the beta draft to change config";
}
if (flags.name !== undefined && flags.name.length > 200) {
return "--name must be at most 200 characters";
}
if (flags.description !== undefined && flags.description.length > 1000) {
return "--description must be at most 1000 characters";
}
if (flags.policy !== undefined && !["turbo", "agentic"].includes(flags.policy)) {
return "--policy must be turbo or agentic";
}
if (flags.temperature !== undefined && (flags.temperature < 0 || flags.temperature > 2)) {
return "--temperature must be between 0 and 2";
}
if (flags.maxLlmCalls !== undefined && (flags.maxLlmCalls < 1 || flags.maxLlmCalls > 30)) {
return "--max-llm-calls must be between 1 and 30";
}
for (const [flagName, value] of [
["--enable-session-file", flags.enableSessionFile],
["--enable-refusal", flags.enableRefusal],
["--enable-anti-leak", flags.enableAntiLeak],
["--enable-rich-text", flags.enableRichText],
["--enable-citation", flags.enableCitation],
] as const) {
if (value !== undefined && !BOOL_CHOICES.includes(value)) {
return `${flagName} must be true or false`;
}
}
return undefined;
},
async run(ctx) {
const { settings, flags } = ctx;
const workspaceId = resolveWorkspaceId(ctx);
const format = detectOutputFormat(settings.output);
const scalarConfig = collectScalarConfig(flags);
const hasConfigChange = flags.configFile !== undefined || Object.keys(scalarConfig).length > 0;
let agentConfig: RagAgentConfig | undefined;
if (flags.configFile !== undefined) {
// dry-run also reads the file and validates (rehearsal semantics)
agentConfig = parseConfigFile(flags.configFile);
} else if (Object.keys(scalarConfig).length > 0) {
if (settings.dryRun) {
// dry-run does not issue the read-merge request; show the scalar delta with a placeholder note
agentConfig = { ...scalarConfig };
} else {
// The API has whole-replace semantics — read the full beta config first,
// then merge (hidden from the user)
const currentConfig = await fetchBetaConfig(ctx.client, workspaceId, flags.agentId);
agentConfig = { ...currentConfig, ...scalarConfig };
validateConfigValues(agentConfig);
}
}
const body = {
agent_id: flags.agentId,
...(flags.name !== undefined ? { agent_name: flags.name } : {}),
...(flags.description !== undefined ? { agent_desc: flags.description } : {}),
...(flags.agentVersion !== undefined ? { agent_version: flags.agentVersion } : {}),
...(flags.versionDesc !== undefined ? { agent_version_desc: flags.versionDesc } : {}),
...(agentConfig !== undefined ? { agent_config: agentConfig } : {}),
};
const endpoint = ragEndpoint(workspaceId, RAG_PATHS.agentUpdate);
if (settings.dryRun) {
emitResult(
{
endpoint,
request: body,
...(hasConfigChange && flags.configFile === undefined
? { note: "scalar config flags are merged into the current beta config at run time" }
: {}),
},
format,
);
return;
}
const response = await ctx.client.requestJson<RagAgentMutationResponse>({
path: endpoint,
method: "POST",
body,
});
if (settings.quiet) return;
if (format === "text") {
emitBare(`updated: ${flags.agentId}`);
if (hasConfigChange) {
emitBare("Draft config changed — verify with --agent-version beta, then deploy.");
}
return;
}
emitResult(response, format);
},
});
@@ -0,0 +1,143 @@
// Shared building blocks for the knowledge admin commands.
import {
BailianError,
ExitCode,
ragEndpoint,
RAG_PATHS,
type Client,
type FlagsDef,
type RagIndexJobDoc,
type RagIndexJobStatusResponse,
type Settings,
} from "bailian-cli-core";
import { poll } from "bailian-cli-runtime";
// Knowledge APIs use a workspace-specific host, so --workspace-id is a per-command
// flag here (the console credential scope does not apply).
export const WORKSPACE_FLAG = {
workspaceId: {
type: "string",
valueHint: "<id>",
description: "Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID)",
},
} satisfies FlagsDef;
// Unified pagination flags for admin list commands. The server-side page/size
// parameter names differ per endpoint (page_number/page_num/pageNum/pageNumber…)
// and the body-vs-query-string placement also varies — each command maps these
// flags to its own API contract. Never pass the flag names through verbatim;
// the CLI-facing flag vocabulary is stable even when the backend is not.
export const PAGE_FLAGS = {
pageNumber: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
pageSize: { type: "number", valueHint: "<n>", description: "Page size per request" },
} satisfies FlagsDef;
/** Three-level fallback: flag > BAILIAN_WORKSPACE_ID env > config (env/config are merged into settings); missing → USAGE. */
export function resolveWorkspaceId(ctx: {
flags: { workspaceId?: string };
settings: { workspaceId?: string };
identity: { binName: string };
}): string {
const workspaceId = ctx.flags.workspaceId || ctx.settings.workspaceId;
if (!workspaceId) {
throw new BailianError(
"Workspace ID is required.",
ExitCode.USAGE,
`Pass --workspace-id, set BAILIAN_WORKSPACE_ID env, or configure: ${ctx.identity.binName} config set workspace_id <id>`,
);
}
return workspaceId;
}
/** Truncate text-mode table rows to the terminal width; no truncation when not a TTY (pipe/redirect). */
export function truncateLine(line: string): string {
if (!process.stdout.isTTY) return line;
const width = process.stdout.columns ?? 120;
return line.length > width ? `${line.slice(0, Math.max(0, width - 1))}` : line;
}
// ---- Shared import-job (index_job/status) logic ----
// Verified against the live API: the overall job state lives in `ingestion_status`
// (PENDING/RUNNING/COMPLETED, no FAILED value); the per-document list is `rows[]`
// and failures surface via `rows[].code` (e.g. PARSE_FAILED).
// Shared by doc status / doc upload / kb create — contract changes only touch this file.
/** Overall job state (ingestion_status) */
export function importJobStatus(response: unknown): string {
return (response as RagIndexJobStatusResponse).data?.ingestion_status ?? "UNKNOWN";
}
/** Per-document failures: rows[].code / status containing FAILED */
export function failedImportDocs(response: RagIndexJobStatusResponse): RagIndexJobDoc[] {
return (response.data?.rows ?? []).filter((doc) =>
[doc.code, doc.status].some((value) => typeof value === "string" && value.includes("FAILED")),
);
}
/** Per-document failure summary: server message passed through verbatim + per-document detail */
export function importJobFailureMessage(
response: RagIndexJobStatusResponse,
fallbackMessage: string,
): string {
const detail = failedImportDocs(response)
.map((doc) => `${doc.doc_name ?? doc.doc_id ?? "?"}: ${doc.message ?? doc.code ?? "unknown"}`)
.join("; ");
const base =
typeof response.message === "string" && response.message ? response.message : fallbackMessage;
return detail ? `${base} (${detail})` : base;
}
/** Build the index_job/status query string (both index_id and job_id are required) */
export function importJobStatusUrl(workspaceId: string, indexId: string, jobId: string): URL {
const url = new URL(ragEndpoint(workspaceId, RAG_PATHS.indexJobStatus));
url.searchParams.set("index_id", indexId);
url.searchParams.set("job_id", jobId);
return url;
}
/**
* Poll the import job until the overall state is COMPLETED.
* The overall state has no FAILED value, so isFailed is always false — failures
* are determined by the caller after return via `failedImportDocs` (semantics:
* the job finished, but some documents failed to parse).
*/
export async function pollImportJob(
client: Client,
settings: Settings,
options: { statusUrl: string; intervalSec: number },
): Promise<RagIndexJobStatusResponse> {
return poll<RagIndexJobStatusResponse>(client, settings, {
url: options.statusUrl,
intervalSec: options.intervalSec,
timeoutSec: settings.timeout,
isComplete: (data) => importJobStatus(data) === "COMPLETED",
isFailed: () => false,
getStatus: (data) => importJobStatus(data),
});
}
/** Attach a hint for partial-success cases while preserving server context (api/rawResponse kept) */
export function withPartialSuccessHint(error: unknown, hint: string): unknown {
if (!(error instanceof BailianError) || error.hint) return error;
return new BailianError(error.message, error.exitCode, hint, {
cause: error,
api: error.api,
rawResponse: error.rawResponse,
});
}
/**
* Read response fields from agent-domain mutation endpoints. Gotcha verified
* against the live API: create/copy return agent_id etc. inside data, but
* deploy returns agent_version/agent_status at the top level next to code —
* the server envelope is inconsistent, so read both locations defensively.
*/
export function agentMutationField(
response: { data?: Record<string, unknown>; [key: string]: unknown },
field: "agent_id" | "agent_name" | "agent_version" | "agent_status",
): string | undefined {
const nested = response.data?.[field];
if (typeof nested === "string") return nested;
const topLevel = response[field];
return typeof topLevel === "string" ? topLevel : undefined;
}
@@ -0,0 +1,126 @@
// Local pre-flight validation for file uploads (doc upload).
// Default category: the lease/addFile `category` parameter accepts the literal
// "default" (verified against the live API), so no listCategory resolution is needed.
import { readFileSync, statSync } from "node:fs";
import { basename, extname } from "node:path";
import { BailianError, ExitCode } from "bailian-cli-core";
const MB = 1024 * 1024;
export interface UploadFormatRule {
maxBytes: number;
/** block: exceeding the limit throws USAGE; warn: only a stderr warning (documented as a recommended limit) */
enforce: "block" | "warn";
}
/**
* Format allowlist based on the officially supported document formats.
* .csv is inconsistent across the public docs — kept in the allowlist for now;
* a server-side rejection would be passed through verbatim.
*/
export const UPLOAD_FORMAT_RULES: Record<string, UploadFormatRule> = {
".doc": { maxBytes: 150 * MB, enforce: "block" },
".docx": { maxBytes: 150 * MB, enforce: "block" },
".ppt": { maxBytes: 150 * MB, enforce: "block" },
".pptx": { maxBytes: 150 * MB, enforce: "block" },
".pdf": { maxBytes: 150 * MB, enforce: "block" },
".png": { maxBytes: 20 * MB, enforce: "block" },
".jpg": { maxBytes: 20 * MB, enforce: "block" },
".jpeg": { maxBytes: 20 * MB, enforce: "block" },
".bmp": { maxBytes: 20 * MB, enforce: "block" },
".gif": { maxBytes: 20 * MB, enforce: "block" },
".xls": { maxBytes: 10 * MB, enforce: "warn" },
".xlsx": { maxBytes: 10 * MB, enforce: "warn" },
".csv": { maxBytes: 10 * MB, enforce: "warn" },
".md": { maxBytes: 10 * MB, enforce: "warn" },
".txt": { maxBytes: 10 * MB, enforce: "warn" },
".html": { maxBytes: 10 * MB, enforce: "warn" },
};
/** Local pre-flight check before reading the file: extension allowlist + hard/soft size limits. File I/O failure → GENERAL + errno hint. */
export function checkUploadFile(filePath: string): { sizeBytes: number; warning?: string } {
const extension = extname(filePath).toLowerCase();
const rule = UPLOAD_FORMAT_RULES[extension];
if (!rule) {
throw new BailianError(
`Unsupported file type: ${extension || basename(filePath)}`,
ExitCode.USAGE,
`Supported formats: ${Object.keys(UPLOAD_FORMAT_RULES).join(" ")}`,
);
}
let sizeBytes: number;
try {
sizeBytes = statSync(filePath).size;
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read file: ${filePath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
if (sizeBytes > rule.maxBytes) {
const limitMb = rule.maxBytes / MB;
if (rule.enforce === "block") {
throw new BailianError(
`File exceeds the ${limitMb} MB limit for ${extension}: ${basename(filePath)}`,
ExitCode.USAGE,
);
}
return {
sizeBytes,
warning: `${basename(filePath)} exceeds the recommended 10 MB for ${extension}; the server may reject or truncate it.`,
};
}
return { sizeBytes };
}
// Known document-format extensions — used to tailor the hint on decode failure
// (pointing users to the document upload flow instead)
const DOCUMENT_EXTENSIONS = new Set([
".doc",
".docx",
".ppt",
".pptx",
".pdf",
".xls",
".xlsx",
".png",
".jpg",
".jpeg",
".bmp",
".gif",
]);
/**
* Read a UTF-8 plain-text file (--content-file for chunk add/update).
* Support is defined by content, not extension: any UTF-8 text is valid.
* Strict decode failure → USAGE, with a hint pointing to the document upload
* flow when the extension is a document format; file I/O failure → GENERAL + errno.
*/
export function readUtf8TextFile(filePath: string): string {
let fileBuffer: Buffer;
try {
fileBuffer = readFileSync(filePath);
} catch (error) {
const errno = (error as { code?: string }).code ?? "unknown";
throw new BailianError(
`Cannot read file: ${filePath}`,
ExitCode.GENERAL,
`File system error (${errno}) — check the path and permissions.`,
);
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(fileBuffer);
} catch {
const extension = extname(filePath).toLowerCase();
const hint = DOCUMENT_EXTENSIONS.has(extension)
? "Document formats cannot be used as chunk content. Upload documents via the document upload command; to edit a chunk, save the text as .md/.txt first."
: "Convert the file to UTF-8 encoding and retry.";
throw new BailianError(
`File is not valid UTF-8 plain text: ${basename(filePath)}`,
ExitCode.USAGE,
hint,
);
}
}
+31
View File
@@ -36,6 +36,37 @@ export { default as memoryProfileGet } from "./commands/memory/profile-get.ts";
export { default as knowledgeRetrieve } from "./commands/knowledge/retrieve.ts";
export { default as knowledgeSearch } from "./commands/knowledge/search.ts";
export { default as knowledgeChat } from "./commands/knowledge/chat.ts";
export { default as knowledgeKbList } from "./commands/knowledge/kb-list.ts";
export { default as knowledgeKbInfo } from "./commands/knowledge/kb-info.ts";
export { default as knowledgeDocList } from "./commands/knowledge/doc-list.ts";
export { default as knowledgeDocStatus } from "./commands/knowledge/doc-status.ts";
export { default as knowledgeDocUpload } from "./commands/knowledge/doc-upload.ts";
export { default as knowledgeKbCreate } from "./commands/knowledge/kb-create.ts";
export { default as knowledgeKbUpdate } from "./commands/knowledge/kb-update.ts";
export { default as knowledgeKbDelete } from "./commands/knowledge/kb-delete.ts";
export { default as knowledgeDocDelete } from "./commands/knowledge/doc-delete.ts";
export { default as knowledgeDocTag } from "./commands/knowledge/doc-tag.ts";
export { default as knowledgeServiceList } from "./commands/knowledge/service-list.ts";
export { default as knowledgeServiceGet } from "./commands/knowledge/service-get.ts";
export { default as knowledgeServiceCreate } from "./commands/knowledge/service-create.ts";
export { default as knowledgeServiceUpdate } from "./commands/knowledge/service-update.ts";
export { default as knowledgeServiceDeploy } from "./commands/knowledge/service-deploy.ts";
export { default as knowledgeServiceDelete } from "./commands/knowledge/service-delete.ts";
export { default as knowledgeServiceCopy } from "./commands/knowledge/service-copy.ts";
export { default as knowledgeChunkAdd } from "./commands/knowledge/chunk-add.ts";
export { default as knowledgeChunkList } from "./commands/knowledge/chunk-list.ts";
export { default as knowledgeChunkUpdate } from "./commands/knowledge/chunk-update.ts";
export { default as knowledgeChunkDelete } from "./commands/knowledge/chunk-delete.ts";
export { default as knowledgeKbStats } from "./commands/knowledge/kb-stats.ts";
export { default as knowledgeCategoryList } from "./commands/knowledge/category-list.ts";
export { default as knowledgeCategoryAdd } from "./commands/knowledge/category-add.ts";
export { default as knowledgeCategoryDelete } from "./commands/knowledge/category-delete.ts";
export { default as knowledgeFileList } from "./commands/knowledge/file-list.ts";
export { default as knowledgeFileGet } from "./commands/knowledge/file-get.ts";
export { default as knowledgeFileDelete } from "./commands/knowledge/file-delete.ts";
export { default as knowledgeCollectionCreate } from "./commands/knowledge/collection-create.ts";
export { default as knowledgeCollectionGet } from "./commands/knowledge/collection-get.ts";
export { default as knowledgeDocImportOss } from "./commands/knowledge/doc-import-oss.ts";
export { default as mcpCall } from "./commands/mcp/call.ts";
export { default as mcpList } from "./commands/mcp/list.ts";
export { default as mcpTools } from "./commands/mcp/tools.ts";
+2
View File
@@ -26,8 +26,10 @@ export {
isBailianE2EMediaEnabled,
isBailianE2EVideoEnabled,
isChatE2EReady,
isConnectorE2EReady,
isConsoleE2EReady,
isDashScopeE2EReady,
isKbAdminE2EReady,
isOpenApiE2EReady,
isSearchE2EReady,
} from "e2e/gating";
@@ -0,0 +1,34 @@
# Journey E2E用户旅程链路测试
从用户 case 出发验证关键流程可用性:每条 journey = 用户带着一个目标跨命令走完整回路,
以「fixture 标记词能否被召回」判定回路闭合(区别于 `../*.e2e.test.ts` 的单命令契约测试)。
## 旅程映射
| # | 用户 Case | 文件 | 闭环断言 |
| --- | ------------------------------------ | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| J1 | 冷启动:建库并获得首个答案 | `j1-cold-start.e2e.test.ts` | retrieve/search(beta) 召回标记词chat 回答引用标记词(软) |
| J2 | 内容运维:文档增删的可见性 | `j2-content-ops.e2e.test.ts` | 双标记词命中 → 删除其一后 markerB 消失且 markerA 仍在(硬) |
| J3 | 检索精修chunk 排除生效 | `j3-chunk-tuning.e2e.test.ts` | exclude 后排除标志生效include 恢复。retrieve 不过滤被排除 chunk`is_displayed_chunk_content` 标志为准 |
| J4 | 服务调优:草稿→修改→发布 | `j4-service-tuning.e2e.test.ts` | beta 草稿可用、update 落库、发布后正式版可用(硬) |
| J5 | 数据面治理collection/category/file | `j5-data-plane.e2e.test.ts` | 自建类目内文件可见/可删,类目删后消失(硬) |
| J6 | 退场清理:删库验证消失 | 复用 [`../knowledge-kb-delete.e2e.test.ts`](../knowledge-kb-delete.e2e.test.ts) live 链 | delete 后 list 不再包含(硬) |
## 约定
- **gating**J1J4 `isKbAdminE2EReady()`J5 `isConnectorE2EReady()`collection 无删除 API仅手动开启gating 函数/环境变量保留 CONNECTOR 旧名)。
- **自建自清**:所有资源自建 + `try/finally` 清理kb 删除走 `deleteKbWithRetry`IndexStatusError 重试);
数据中心文件用 `knowledge file delete` 回收;清理失败不掩盖,落 `resources.json` 供人工回收。
- **软/硬断言**可用性关键路径硬断言fail依赖服务端语义/延迟波动的信号软断言
`recordSoft`,只落报告不 fail人工复核
- **日志产物**:每次 live 运行在 `test/output/<session>/e2e-vp-<journey>-<ts>/` 生成
`journey-report.md`(步骤表 + 软断言区 + 未清理资源警示)、分步 stdout/stderr、`resources.json``journey.log`
## 运行
```sh
pnpm run test:journey # 全部 journey无凭证时全部 skip
vp test packages/commands/tests/e2e/knowledge/journeys/j1-cold-start.e2e.test.ts
```
live 运行需 `.env``BAILIAN_E2E=1` + DashScope API key + `BAILIAN_WORKSPACE_ID`J5 另需 `BAILIAN_E2E_CONNECTOR=1`
@@ -0,0 +1,177 @@
// J1 cold-start first answer: from zero to the first citable answer.
// upload → kb create --wait → retrieve recalls the marker → service create (search/chat, draft)
// → search --agent-version beta recalls → chat --agent-version beta first answer.
// Closure assertions: the fixture marker is recalled by retrieve/search (hard);
// the chat answer cites the marker (soft).
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson } from "../../helpers.ts";
import { JOURNEY_J1_ROUTES } from "../../topic-routes.ts";
import {
cleanupKbFixture,
createJourneyReporter,
createKbWithDocs,
patchSearchServiceRetrievalConfig,
pollUntil,
uniqueMarker,
type KbFixture,
} from "./journey-helpers.ts";
describe.skipIf(!isKbAdminE2EReady())("journey J1: 冷启动首答 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("upload → create → retrieve → search(beta) → chat(beta) 回路闭合", async () => {
const reporter = createJourneyReporter(import.meta.url);
const marker = uniqueMarker("j1");
const fixture: Partial<KbFixture> = {};
const serviceAgentIds: string[] = [];
try {
// 1) Upload + create the knowledge base (--wait for the initial import)
const kb = await createKbWithDocs(reporter, JOURNEY_J1_ROUTES, "j1", [marker], workspaceId);
Object.assign(fixture, kb);
// 2) retrieve directly against the base recalls the marker (hard assertion,
// polling to absorb index-visibility lag)
// Gotcha: retrieve is a legacy DashScope-host command and does not accept --workspace-id
const retrievePoll = await pollUntil(
() =>
reporter.runStep("retrieve marker", JOURNEY_J1_ROUTES, [
"knowledge",
"retrieve",
"--index-id",
kb.indexId,
"--query",
marker,
"--output",
"json",
]),
(run) => run.exitCode === 0 && run.stdout.includes(marker),
{ timeoutMs: 180_000, intervalMs: 15_000 },
);
reporter.recordNote(`retrieve 轮询 ${retrievePoll.attempts}`);
expect(retrievePoll.satisfied, `retrieve 未召回标记词 ${marker}`).toBe(true);
// 3) Create a retrieval service (initial draft/beta); search on the draft recalls (hard assertion)
const searchServiceRun = await reporter.runStep(
"service create (search)",
JOURNEY_J1_ROUTES,
[
"knowledge",
"service",
"create",
"--name",
`e2e-j1-s-${Date.now() % 100000000}`,
"--scene",
"search",
"--index-id",
kb.indexId,
"--workspace-id",
workspaceId,
"--quiet",
],
);
expect(searchServiceRun.exitCode, searchServiceRun.stderr).toBe(0);
const searchAgentId = searchServiceRun.stdout.trim().split("\n").pop()!;
expect(searchAgentId).toMatch(/^aid-/);
reporter.trackResource("service", searchAgentId);
serviceAgentIds.push(searchAgentId);
// Server gotcha: a minimally created search service is missing required
// retrieval parameters — backfill before calling (see journey-helpers)
await patchSearchServiceRetrievalConfig(
reporter,
JOURNEY_J1_ROUTES,
searchAgentId,
workspaceId,
);
const searchPoll = await pollUntil(
() =>
reporter.runStep("search marker (beta)", JOURNEY_J1_ROUTES, [
"knowledge",
"search",
"--query",
marker,
"--agent-id",
searchAgentId,
"--agent-version",
"beta",
"--workspace-id",
workspaceId,
"--output",
"json",
]),
(run) => run.exitCode === 0 && run.stdout.includes(marker),
{ timeoutMs: 120_000, intervalMs: 15_000 },
);
reporter.recordNote(`search 轮询 ${searchPoll.attempts}`);
expect(searchPoll.satisfied, `search(beta) 未召回标记词 ${marker}`).toBe(true);
// 4) Create a chat service; first answer on the draft (non-empty answer is a
// hard assertion; citing the marker is a soft assertion for manual review)
const chatServiceRun = await reporter.runStep("service create (chat)", JOURNEY_J1_ROUTES, [
"knowledge",
"service",
"create",
"--name",
`e2e-j1-c-${Date.now() % 100000000}`,
"--scene",
"chat",
"--index-id",
kb.indexId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(chatServiceRun.exitCode, chatServiceRun.stderr).toBe(0);
const chatAgentId = chatServiceRun.stdout.trim().split("\n").pop()!;
expect(chatAgentId).toMatch(/^aid-/);
reporter.trackResource("service", chatAgentId);
serviceAgentIds.push(chatAgentId);
const chatRun = await reporter.runStep("chat first answer (beta)", JOURNEY_J1_ROUTES, [
"knowledge",
"chat",
"--message",
`What is ${marker}? Answer strictly based on the knowledge base.`,
"--agent-id",
chatAgentId,
"--agent-version",
"beta",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(chatRun.exitCode, chatRun.stderr).toBe(0);
const chatData = parseStdoutJson<{ answer?: string }>(chatRun.stdout);
const answerText = chatData.answer?.trim() ?? "";
expect(answerText.length, "chat 回答为空").toBeGreaterThan(0);
reporter.recordSoft(
"chat 回答引用 fixture 标记词",
answerText.includes(marker),
answerText || "(empty)",
);
} finally {
for (const agentId of serviceAgentIds) {
const deleteRun = await reporter.runStep(
`cleanup: service delete ${agentId}`,
JOURNEY_J1_ROUTES,
[
"knowledge",
"service",
"delete",
"--agent-id",
agentId,
"--yes",
"--workspace-id",
workspaceId,
],
);
if (deleteRun.exitCode === 0) reporter.markCleaned(agentId);
}
await cleanupKbFixture(reporter, JOURNEY_J1_ROUTES, fixture, workspaceId);
reporter.finalize();
}
// Observed create times vary widely plus three polling phases — timeout sized for the worst path
}, 900_000);
});
@@ -0,0 +1,175 @@
// J2 content operations: routine document add/remove and the visibility loop.
// create (doc1+doc2) → doc list/status checks → doc tag → retrieve hits both markers
// → doc delete doc2 → retrieve verifies markerB is gone and markerA remains.
// Note: there is no "append documents to an existing base" command (kb update takes
// no doc-id), so incremental semantics are expressed as a two-document create + deleting one.
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady } from "../../helpers.ts";
import { JOURNEY_J2_ROUTES } from "../../topic-routes.ts";
import {
cleanupKbFixture,
createJourneyReporter,
createKbWithDocs,
pollUntil,
uniqueMarker,
type KbFixture,
} from "./journey-helpers.ts";
describe.skipIf(!isKbAdminE2EReady())("journey J2: 内容运维 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("建库(双文档) → list/status/tag → 删除其一 → 召回可见性闭环", async () => {
const reporter = createJourneyReporter(import.meta.url);
const markerA = uniqueMarker("j2a");
const markerB = uniqueMarker("j2b");
const fixture: Partial<KbFixture> = {};
try {
// 1) Create the base with two documents
const kb = await createKbWithDocs(
reporter,
JOURNEY_J2_ROUTES,
"j2",
[markerA, markerB],
workspaceId,
);
Object.assign(fixture, kb);
const [fileIdA, fileIdB] = kb.fileIds as [string, string];
// 2) doc list contains both documents (hard); doc status terminal state COMPLETED (hard)
const docListRun = await reporter.runStep("doc list", JOURNEY_J2_ROUTES, [
"knowledge",
"doc",
"list",
"--index-id",
kb.indexId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(docListRun.exitCode, docListRun.stderr).toBe(0);
const listedDocIds = docListRun.stdout.trim().split("\n");
expect(listedDocIds).toContain(fileIdA);
expect(listedDocIds).toContain(fileIdB);
const docStatusRun = await reporter.runStep("doc status", JOURNEY_J2_ROUTES, [
"knowledge",
"doc",
"status",
"--index-id",
kb.indexId,
"--job-id",
kb.jobId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(docStatusRun.exitCode, docStatusRun.stderr).toBe(0);
expect(docStatusRun.stdout.trim()).toBe("COMPLETED");
// 3) doc tag on doc2 (tagging succeeds: hard; tag read-back: soft — server-side
// display lag or the API not echoing tags are both tolerable)
const tagValue = `e2e-j2-${Date.now() % 100000000}`;
const docTagRun = await reporter.runStep("doc tag doc2", JOURNEY_J2_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
fileIdB,
"--tag",
tagValue,
"--workspace-id",
workspaceId,
]);
expect(docTagRun.exitCode, docTagRun.stderr).toBe(0);
const tagReadbackRun = await reporter.runStep("doc list (tag readback)", JOURNEY_J2_ROUTES, [
"knowledge",
"doc",
"list",
"--index-id",
kb.indexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
reporter.recordSoft(
"doc list 回读可见新标签",
tagReadbackRun.exitCode === 0 && tagReadbackRun.stdout.includes(tagValue),
`tag=${tagValue} exit=${tagReadbackRun.exitCode}`,
);
// 4) retrieve hits both markers (hard, polling to absorb indexing lag)
// Gotcha: retrieve is a legacy DashScope-host command and does not accept --workspace-id
const retrieveMarker = (marker: string, stepName: string) =>
pollUntil(
() =>
reporter.runStep(stepName, JOURNEY_J2_ROUTES, [
"knowledge",
"retrieve",
"--index-id",
kb.indexId,
"--query",
marker,
"--output",
"json",
]),
(run) => run.exitCode === 0 && run.stdout.includes(marker),
{ timeoutMs: 180_000, intervalMs: 15_000 },
);
const pollA = await retrieveMarker(markerA, "retrieve markerA");
expect(pollA.satisfied, `retrieve 未召回 ${markerA}`).toBe(true);
const pollB = await retrieveMarker(markerB, "retrieve markerB");
expect(pollB.satisfied, `retrieve 未召回 ${markerB}`).toBe(true);
// 5) Delete doc2 → markerB no longer recalled while markerA remains (hard)
const docDeleteRun = await reporter.runStep("doc delete doc2", JOURNEY_J2_ROUTES, [
"knowledge",
"doc",
"delete",
"--index-id",
kb.indexId,
"--doc-id",
fileIdB,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(docDeleteRun.exitCode, docDeleteRun.stderr).toBe(0);
const goneB = await pollUntil(
() =>
reporter.runStep("retrieve markerB (expect miss)", JOURNEY_J2_ROUTES, [
"knowledge",
"retrieve",
"--index-id",
kb.indexId,
"--query",
markerB,
"--output",
"json",
]),
(run) => run.exitCode === 0 && !run.stdout.includes(markerB),
{ timeoutMs: 180_000, intervalMs: 15_000 },
);
reporter.recordNote(`doc delete 后 markerB 消失轮询 ${goneB.attempts}`);
expect(goneB.satisfied, `doc delete 后 ${markerB} 仍可召回`).toBe(true);
const stillA = await reporter.runStep("retrieve markerA (still hit)", JOURNEY_J2_ROUTES, [
"knowledge",
"retrieve",
"--index-id",
kb.indexId,
"--query",
markerA,
"--output",
"json",
]);
expect(stillA.exitCode, stillA.stderr).toBe(0);
expect(stillA.stdout, `doc delete 误伤: ${markerA} 不再召回`).toContain(markerA);
} finally {
await cleanupKbFixture(reporter, JOURNEY_J2_ROUTES, fixture, workspaceId);
reporter.finalize();
}
// Base creation plus several polling phases — timeout sized for the worst path
}, 900_000);
});
@@ -0,0 +1,166 @@
// J3 retrieval tuning: the real effect of chunk-level exclusion on recall.
// create → chunk list locates the marker chunk → chunk update --exclude → retrieve
// asserts the exclusion flag (hard) → chunk update --include restores → retrieve
// asserts restoration (soft, index-refresh lag is tolerable).
// Semantic gotcha (verified live): the legacy retrieve endpoint does not filter
// excluded chunks; it only sets metadata.is_displayed_chunk_content to false —
// that flag is the closure signal.
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson } from "../../helpers.ts";
import { JOURNEY_J3_ROUTES } from "../../topic-routes.ts";
import {
cleanupKbFixture,
createJourneyReporter,
createKbWithDocs,
pollUntil,
uniqueMarker,
type KbFixture,
} from "./journey-helpers.ts";
interface ChunkListResponse {
data?: {
nodes?: Array<{
text?: string;
metadata?: { _id?: string; doc_id?: string; content?: string };
}>;
};
}
interface RetrieveResponse {
data?: {
nodes?: Array<{
text?: string;
metadata?: { content?: string; is_displayed_chunk_content?: boolean };
}>;
};
}
/** Exclusion flag of the node containing the marker; undefined when the marker is not found */
function markerNodeDisplayFlag(stdout: string, marker: string): boolean | undefined {
let response: RetrieveResponse;
try {
response = parseStdoutJson<RetrieveResponse>(stdout);
} catch {
return undefined;
}
const markedNode = (response.data?.nodes ?? []).find((node) =>
(node.metadata?.content ?? node.text ?? "").includes(marker),
);
if (!markedNode) return undefined;
return markedNode.metadata?.is_displayed_chunk_content !== false;
}
describe.skipIf(!isKbAdminE2EReady())("journey J3: 检索精修 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("chunk exclude → 排除标志生效 → include 恢复", async () => {
const reporter = createJourneyReporter(import.meta.url);
const marker = uniqueMarker("j3");
const fixture: Partial<KbFixture> = {};
try {
// 1) Create the base and confirm the marker is recallable (baseline for judging exclude)
const kb = await createKbWithDocs(reporter, JOURNEY_J3_ROUTES, "j3", [marker], workspaceId);
Object.assign(fixture, kb);
// Gotcha: retrieve is a legacy DashScope-host command and does not accept --workspace-id
const retrieveArgs = [
"knowledge",
"retrieve",
"--index-id",
kb.indexId,
"--query",
marker,
"--output",
"json",
];
const baseline = await pollUntil(
() => reporter.runStep("retrieve baseline", JOURNEY_J3_ROUTES, retrieveArgs),
(run) => run.exitCode === 0 && run.stdout.includes(marker),
{ timeoutMs: 180_000, intervalMs: 15_000 },
);
expect(baseline.satisfied, `基线未召回标记词 ${marker}`).toBe(true);
// 2) chunk list locates the chunk containing the marker (metadata._id / doc_id feed update)
const chunkListRun = await reporter.runStep("chunk list", JOURNEY_J3_ROUTES, [
"knowledge",
"chunk",
"list",
"--index-id",
kb.indexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(chunkListRun.exitCode, chunkListRun.stderr).toBe(0);
const chunkData = parseStdoutJson<ChunkListResponse>(chunkListRun.stdout);
const markedNode = (chunkData.data?.nodes ?? []).find((node) =>
(node.metadata?.content ?? node.text ?? "").includes(marker),
);
expect(markedNode, `chunk list 中未找到含 ${marker} 的切片`).toBeTruthy();
const chunkId = markedNode!.metadata?._id ?? "";
const chunkDocId = markedNode!.metadata?.doc_id ?? "";
expect(chunkId).toBeTruthy();
expect(chunkDocId).toBeTruthy();
reporter.recordNote(`目标 chunk=${chunkId} doc=${chunkDocId}`);
// 3) exclude → the exclusion flag takes effect on the marker node (hard)
const excludeRun = await reporter.runStep("chunk update --exclude", JOURNEY_J3_ROUTES, [
"knowledge",
"chunk",
"update",
"--index-id",
kb.indexId,
"--chunk-id",
chunkId,
"--doc-id",
chunkDocId,
"--exclude",
"--workspace-id",
workspaceId,
]);
expect(excludeRun.exitCode, excludeRun.stderr).toBe(0);
const excluded = await pollUntil(
() => reporter.runStep("retrieve after exclude", JOURNEY_J3_ROUTES, retrieveArgs),
(run) => run.exitCode === 0 && markerNodeDisplayFlag(run.stdout, marker) === false,
{ timeoutMs: 180_000, intervalMs: 15_000 },
);
reporter.recordNote(`exclude 生效轮询 ${excluded.attempts}`);
expect(excluded.satisfied, `exclude 后 ${marker} 所在 chunk 未标记为排除`).toBe(true);
// 4) include restores → exclusion flag cleared (soft: index-refresh lag on the
// restore path varies a lot; result goes to the report for manual review)
const includeRun = await reporter.runStep("chunk update --include", JOURNEY_J3_ROUTES, [
"knowledge",
"chunk",
"update",
"--index-id",
kb.indexId,
"--chunk-id",
chunkId,
"--doc-id",
chunkDocId,
"--include",
"--workspace-id",
workspaceId,
]);
expect(includeRun.exitCode, includeRun.stderr).toBe(0);
const restored = await pollUntil(
() => reporter.runStep("retrieve after include", JOURNEY_J3_ROUTES, retrieveArgs),
(run) => run.exitCode === 0 && markerNodeDisplayFlag(run.stdout, marker) === true,
{ timeoutMs: 120_000, intervalMs: 15_000 },
);
reporter.recordSoft(
"include 恢复后排除标志解除",
restored.satisfied,
`attempts=${restored.attempts} exit=${restored.value.exitCode}`,
);
} finally {
await cleanupKbFixture(reporter, JOURNEY_J3_ROUTES, fixture, workspaceId);
reporter.finalize();
}
// Base creation plus three polling phases — timeout sized for the worst path
}, 900_000);
});
@@ -0,0 +1,165 @@
// J4 service tuning: draft (beta) usable → config change persisted → released version usable after deploy.
// create + service create (search, initial draft/beta) → search --agent-version beta recalls (hard)
// → service update --description → service get asserts the change (hard) → deploy → released search recalls (hard).
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson } from "../../helpers.ts";
import { JOURNEY_J4_ROUTES } from "../../topic-routes.ts";
import {
cleanupKbFixture,
createJourneyReporter,
createKbWithDocs,
patchSearchServiceRetrievalConfig,
pollUntil,
uniqueMarker,
type KbFixture,
} from "./journey-helpers.ts";
describe.skipIf(!isKbAdminE2EReady())("journey J4: 问答服务调优 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("draft 可用 → update 落库 → deploy → 正式版可用", async () => {
const reporter = createJourneyReporter(import.meta.url);
const marker = uniqueMarker("j4");
const fixture: Partial<KbFixture> = {};
let agentId = "";
try {
// 1) Create the base + a retrieval service bound to it (initial draft/beta)
const kb = await createKbWithDocs(reporter, JOURNEY_J4_ROUTES, "j4", [marker], workspaceId);
Object.assign(fixture, kb);
const createServiceRun = await reporter.runStep(
"service create (search)",
JOURNEY_J4_ROUTES,
[
"knowledge",
"service",
"create",
"--name",
`e2e-j4-${Date.now() % 100000000}`,
"--scene",
"search",
"--index-id",
kb.indexId,
"--workspace-id",
workspaceId,
"--quiet",
],
);
expect(createServiceRun.exitCode, createServiceRun.stderr).toBe(0);
agentId = createServiceRun.stdout.trim().split("\n").pop()!;
expect(agentId).toMatch(/^aid-/);
reporter.trackResource("service", agentId);
// Server gotcha: a minimally created search service is missing required
// retrieval parameters — backfill before calling (see journey-helpers)
await patchSearchServiceRetrievalConfig(reporter, JOURNEY_J4_ROUTES, agentId, workspaceId);
// 2) Draft search recalls (hard, polling to absorb index/service propagation lag)
const betaSearchArgs = [
"knowledge",
"search",
"--query",
marker,
"--agent-id",
agentId,
"--agent-version",
"beta",
"--workspace-id",
workspaceId,
"--output",
"json",
];
const betaPoll = await pollUntil(
() => reporter.runStep("search (beta)", JOURNEY_J4_ROUTES, betaSearchArgs),
(run) => run.exitCode === 0 && run.stdout.includes(marker),
{ timeoutMs: 180_000, intervalMs: 15_000 },
);
reporter.recordNote(`beta search 轮询 ${betaPoll.attempts}`);
expect(betaPoll.satisfied, `search(beta) 未召回标记词 ${marker}`).toBe(true);
// 3) update the description (top-level scalar, valid for the search scene too) → get asserts persistence (hard)
const newDescription = `journey j4 tuned at ${Date.now()}`;
const updateRun = await reporter.runStep("service update --description", JOURNEY_J4_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
agentId,
"--description",
newDescription,
"--workspace-id",
workspaceId,
]);
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
const getRun = await reporter.runStep("service get (beta)", JOURNEY_J4_ROUTES, [
"knowledge",
"service",
"get",
"--agent-id",
agentId,
"--agent-version",
"beta",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(getRun.exitCode, getRun.stderr).toBe(0);
const getData = parseStdoutJson<{ data?: { agent_desc?: string } }>(getRun.stdout);
expect(getData.data?.agent_desc).toBe(newDescription);
// 4) deploy → released search (without --agent-version) recalls (hard)
const deployRun = await reporter.runStep("service deploy", JOURNEY_J4_ROUTES, [
"knowledge",
"service",
"deploy",
"--agent-id",
agentId,
"--yes",
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(deployRun.exitCode, deployRun.stderr).toBe(0);
reporter.recordNote(`deploy 版本号: ${deployRun.stdout.trim().split("\n").pop() ?? "?"}`);
const releasedPoll = await pollUntil(
() =>
reporter.runStep("search (released)", JOURNEY_J4_ROUTES, [
"knowledge",
"search",
"--query",
marker,
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]),
(run) => run.exitCode === 0 && run.stdout.includes(marker),
{ timeoutMs: 120_000, intervalMs: 15_000 },
);
reporter.recordNote(`正式版 search 轮询 ${releasedPoll.attempts}`);
expect(releasedPoll.satisfied, `发布后正式版 search 未召回 ${marker}`).toBe(true);
} finally {
if (agentId) {
const deleteRun = await reporter.runStep("cleanup: service delete", JOURNEY_J4_ROUTES, [
"knowledge",
"service",
"delete",
"--agent-id",
agentId,
"--yes",
"--workspace-id",
workspaceId,
]);
if (deleteRun.exitCode === 0) reporter.markCleaned(agentId);
}
await cleanupKbFixture(reporter, JOURNEY_J4_ROUTES, fixture, workspaceId);
reporter.finalize();
}
// Base creation plus two polling phases plus deploy — timeout sized for the worst path
}, 900_000);
});
@@ -0,0 +1,186 @@
// J5 data-plane governance: the collection/category/file read-write loop (decoupled from the index plane).
// collection reuse-or-create (no delete API, fixed-name idempotent) → category add → visible in list
// → upload a file into the self-created category → file list/get → file delete
// → category delete → list verifies removal.
// gating: collection artifacts cannot be cleaned up — only enable explicitly for a
// full manual regression (BAILIAN_E2E_CONNECTOR=1).
import { describe, expect, test } from "vite-plus/test";
import { isConnectorE2EReady, parseStdoutJson } from "../../helpers.ts";
import { JOURNEY_J5_ROUTES } from "../../topic-routes.ts";
import { createJourneyReporter, uniqueMarker, writeMarkerFixture } from "./journey-helpers.ts";
describe.skipIf(!isConnectorE2EReady())("journey J5: 数据面治理 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("collection → category → file 读写 → 类目清理闭环", async () => {
const reporter = createJourneyReporter(import.meta.url);
let categoryId = "";
let categoryName = "";
try {
// 1) collection reuse-or-create (fixed-name idempotent — avoid accumulating undeletable collections)
// Server rejects names longer than 20 characters ("Connector name is longer than 20")
const collectionName = "e2e-journey-coll";
const collectionGetRun = await reporter.runStep("collection get by name", JOURNEY_J5_ROUTES, [
"knowledge",
"collection",
"get",
"--name",
collectionName,
"--workspace-id",
workspaceId,
"--quiet",
]);
let collectionId = collectionGetRun.exitCode === 0 ? collectionGetRun.stdout.trim() : "";
if (!collectionId) {
const collectionCreateRun = await reporter.runStep("collection create", JOURNEY_J5_ROUTES, [
"knowledge",
"collection",
"create",
"--name",
collectionName,
"--description",
"journey J5 fixture collection (PLATFORM, reused across runs)",
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(collectionCreateRun.exitCode, collectionCreateRun.stderr).toBe(0);
collectionId = collectionCreateRun.stdout.trim();
}
expect(collectionId).toBeTruthy();
reporter.trackResource("collection", collectionId, { persistent: true });
// 2) Self-created category → visible via exact-name filtering in list (hard)
categoryName = `e2e-j5-${Date.now() % 100000000}`;
const categoryAddRun = await reporter.runStep("category add", JOURNEY_J5_ROUTES, [
"knowledge",
"category",
"add",
"--name",
categoryName,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(categoryAddRun.exitCode, categoryAddRun.stderr).toBe(0);
categoryId = categoryAddRun.stdout.trim();
expect(categoryId).toMatch(/^cate_/);
reporter.trackResource("category", categoryId);
const categoryListRun = await reporter.runStep("category list (filter)", JOURNEY_J5_ROUTES, [
"knowledge",
"category",
"list",
"--name",
categoryName,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(categoryListRun.exitCode, categoryListRun.stderr).toBe(0);
expect(categoryListRun.stdout.trim().split("\n")).toContain(categoryId);
// 3) Upload a file into the self-created category → file list/get → file delete
// (only touch self-created artifacts)
const marker = uniqueMarker("j5");
const filePath = writeMarkerFixture("j5", marker);
const uploadRun = await reporter.runStep("doc upload to category", JOURNEY_J5_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
filePath,
"--category-id",
categoryId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(uploadRun.exitCode, uploadRun.stderr).toBe(0);
const fileId = uploadRun.stdout.trim();
expect(fileId).toMatch(/^file_/);
reporter.trackResource("data-center-file", fileId);
const fileListRun = await reporter.runStep("file list", JOURNEY_J5_ROUTES, [
"knowledge",
"file",
"list",
"--category-id",
categoryId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(fileListRun.exitCode, fileListRun.stderr).toBe(0);
expect(fileListRun.stdout.trim().split("\n")).toContain(fileId);
const fileGetRun = await reporter.runStep("file get", JOURNEY_J5_ROUTES, [
"knowledge",
"file",
"get",
"--file-id",
fileId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(fileGetRun.exitCode, fileGetRun.stderr).toBe(0);
const fileDetail = parseStdoutJson<{ data?: { category?: string } }>(fileGetRun.stdout);
expect(fileDetail.data?.category).toBe(categoryId);
const fileDeleteRun = await reporter.runStep("file delete", JOURNEY_J5_ROUTES, [
"knowledge",
"file",
"delete",
"--file-id",
fileId,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
reporter.markCleaned(fileId);
} finally {
if (categoryId) {
// 4) Clean up the self-created category + list verifies removal
// (doubles as live coverage of category delete)
const categoryDeleteRun = await reporter.runStep(
"cleanup: category delete",
JOURNEY_J5_ROUTES,
[
"knowledge",
"category",
"delete",
"--category-id",
categoryId,
"--yes",
"--workspace-id",
workspaceId,
],
);
if (categoryDeleteRun.exitCode === 0) {
reporter.markCleaned(categoryId);
const goneListRun = await reporter.runStep(
"category list (verify gone)",
JOURNEY_J5_ROUTES,
[
"knowledge",
"category",
"list",
"--name",
categoryName,
"--workspace-id",
workspaceId,
"--quiet",
],
);
if (goneListRun.exitCode === 0) {
expect(goneListRun.stdout.trim().split("\n")).not.toContain(categoryId);
}
}
}
reporter.finalize();
}
}, 300_000);
});
@@ -0,0 +1,451 @@
// Shared journey-level infrastructure: marker-word fixtures, eventual-consistency
// polling, IndexStatusError retrying deletes, and an on-disk reporter (per-step
// stdout/stderr, soft assertions, resource inventory, human-readable report).
import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
e2eLabelFromMetaUrl,
makeE2eOutputDir,
parseStdoutJson,
runCommandE2e,
type RunCliResult,
} from "../../helpers.ts";
import type { E2eRouteExports } from "../../topic-routes.ts";
// ---- Marker words and fixtures ----
/** Unique marker word: journey closure is judged by whether it can be recalled */
export function uniqueMarker(journeyId: string): string {
return `E2E_MARKER_${journeyId.toUpperCase()}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
}
/** Write a temporary md fixture containing the marker word; returns the file path */
export function writeMarkerFixture(journeyId: string, marker: string): string {
const fixtureDir = mkdtempSync(join(tmpdir(), `journey-${journeyId}-`));
const filePath = join(fixtureDir, `${journeyId}-${Date.now()}.md`);
writeFileSync(
filePath,
[
`# Journey fixture for ${journeyId}`,
"",
`The secret code word of this document is ${marker}.`,
`${marker} is a fictional internal project name used only by CLI journey tests.`,
`When asked about ${marker}, this document is the authoritative source.`,
"",
].join("\n"),
);
return filePath;
}
// ---- Eventual-consistency polling ----
export interface PollOutcome<T> {
value: T;
satisfied: boolean;
attempts: number;
}
export async function sleep(milliseconds: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, milliseconds));
}
/**
* Poll until the predicate holds or the timeout is reached. Never throws —
* returns satisfied and lets the caller decide hard vs soft assertion.
*/
export async function pollUntil<T>(
action: () => Promise<T>,
predicate: (value: T) => boolean,
options: { timeoutMs?: number; intervalMs?: number } = {},
): Promise<PollOutcome<T>> {
const timeoutMs = options.timeoutMs ?? 120_000;
const intervalMs = options.intervalMs ?? 10_000;
const deadline = Date.now() + timeoutMs;
let attempts = 0;
let value = await action();
attempts += 1;
while (!predicate(value) && Date.now() < deadline) {
await sleep(intervalMs);
value = await action();
attempts += 1;
}
return { value, satisfied: predicate(value), attempts };
}
// ---- Delete with IndexStatusError retry (server gotcha: briefly undeletable right after import) ----
export async function deleteKbWithRetry(
runner: (args: string[]) => Promise<RunCliResult>,
indexId: string,
workspaceId: string,
): Promise<RunCliResult> {
const args = [
"knowledge",
"delete",
"--index-id",
indexId,
"--yes",
"--workspace-id",
workspaceId,
];
let deleteRun = await runner(args);
for (let retry = 0; retry < 5 && deleteRun.exitCode !== 0; retry++) {
if (!deleteRun.stderr.includes("IndexStatusError")) break; // other errors are not retried
await sleep(10_000);
deleteRun = await runner(args);
}
return deleteRun;
}
// ---- Journey reporter: artifacts for manual review ----
interface JourneyStepRecord {
index: number;
name: string;
args: string[];
exitCode: number | null;
durationMs: number;
stdoutFile: string;
stderrFile: string;
}
interface SoftAssertionRecord {
name: string;
passed: boolean;
detail: string;
}
interface TrackedResource {
type: string;
id: string;
cleaned: boolean;
/** Resources expected to persist (e.g. no delete API); excluded from the uncleaned warning */
persistent: boolean;
}
export interface JourneyReporter {
outputDir: string;
runStep(
name: string,
routes: E2eRouteExports,
args: string[],
envOverrides?: NodeJS.ProcessEnv,
): Promise<RunCliResult>;
recordSoft(name: string, passed: boolean, detail: string): void;
recordNote(text: string): void;
trackResource(type: string, id: string, options?: { persistent?: boolean }): void;
markCleaned(id: string): void;
/** Inventory of uncleaned resources (queryable before finalize, for afterAll fallbacks) */
uncleanedResources(): TrackedResource[];
finalize(): void;
}
function sanitizeStepName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 60);
}
export function createJourneyReporter(metaUrl: string): JourneyReporter {
const outputDir = makeE2eOutputDir(e2eLabelFromMetaUrl(metaUrl));
const logFile = join(outputDir, "journey.log");
const steps: JourneyStepRecord[] = [];
const softAssertions: SoftAssertionRecord[] = [];
const notes: string[] = [];
const resources: TrackedResource[] = [];
function appendLog(line: string): void {
appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`);
}
function flushResources(): void {
writeFileSync(join(outputDir, "resources.json"), `${JSON.stringify(resources, null, 2)}\n`);
}
return {
outputDir,
async runStep(name, routes, args, envOverrides = {}) {
const index = steps.length + 1;
const prefix = `${String(index).padStart(2, "0")}-${sanitizeStepName(name)}`;
const startedAt = Date.now();
// Redaction convention: only CLI args are persisted (credentials travel via
// env/config, never in args); env is not persisted
const result = await runCommandE2e(routes, args, envOverrides);
const durationMs = Date.now() - startedAt;
const stdoutFile = `${prefix}.stdout.txt`;
const stderrFile = `${prefix}.stderr.txt`;
writeFileSync(join(outputDir, stdoutFile), result.stdout);
writeFileSync(join(outputDir, stderrFile), result.stderr);
steps.push({
index,
name,
args,
exitCode: result.exitCode,
durationMs,
stdoutFile,
stderrFile,
});
appendLog(`[${prefix}] exit=${result.exitCode} ${durationMs}ms args: ${args.join(" ")}`);
return result;
},
recordSoft(name, passed, detail) {
softAssertions.push({ name, passed, detail });
appendLog(`[soft] ${passed ? "PASS" : "MISS"} ${name}`);
},
recordNote(text) {
notes.push(text);
appendLog(`[note] ${text}`);
},
trackResource(type, id, options = {}) {
resources.push({ type, id, cleaned: false, persistent: options.persistent ?? false });
flushResources();
},
markCleaned(id) {
const resource = resources.find((candidate) => candidate.id === id && !candidate.cleaned);
if (resource) resource.cleaned = true;
flushResources();
},
uncleanedResources() {
return resources.filter((resource) => !resource.cleaned && !resource.persistent);
},
finalize() {
flushResources();
const leaked = resources.filter((resource) => !resource.cleaned && !resource.persistent);
const lines: string[] = [
`# Journey report`,
"",
`- generated: ${new Date().toISOString()}`,
`- output dir: ${outputDir}`,
"",
"## Steps",
"",
"| # | step | exit | duration | stdout |",
"| --- | --- | --- | --- | --- |",
...steps.map(
(step) =>
`| ${step.index} | ${step.name} | ${step.exitCode} | ${step.durationMs}ms | ${step.stdoutFile} |`,
),
"",
"## Soft assertions (manual review)",
"",
];
if (softAssertions.length === 0) {
lines.push("(none)");
} else {
for (const softAssertion of softAssertions) {
lines.push(
`### ${softAssertion.passed ? "✅" : "⚠️"} ${softAssertion.name}`,
"",
"```",
softAssertion.detail,
"```",
"",
);
}
}
lines.push("", "## Notes", "");
lines.push(...(notes.length === 0 ? ["(none)"] : notes.map((note) => `- ${note}`)));
lines.push("", "## Resources", "");
lines.push("| type | id | cleaned |", "| --- | --- | --- |");
lines.push(
...resources.map(
(resource) =>
`| ${resource.type} | ${resource.id} | ${resource.persistent ? "retained (persistent)" : String(resource.cleaned)} |`,
),
);
if (leaked.length > 0) {
lines.push(
"",
"## ⚠️ Uncleaned resources (manual cleanup required)",
"",
...leaked.map((resource) => `- ${resource.type}: ${resource.id}`),
);
}
lines.push("");
writeFileSync(join(outputDir, "journey-report.md"), lines.join("\n"));
appendLog(
`[finalize] steps=${steps.length} soft=${softAssertions.length} leaked=${leaked.length}`,
);
},
};
}
// ---- Shared J1J4 setup: upload marker documents + create a knowledge base ----
export interface KbFixture {
indexId: string;
jobId: string;
fileIds: string[];
}
/**
* Upload N marker documents and run `kb create --wait`. Any failure throws
* (message includes stderr). Artifacts (data-center file / kb) are tracked via
* trackResource; cleanup is the caller's responsibility.
*/
export async function createKbWithDocs(
reporter: JourneyReporter,
routes: E2eRouteExports,
journeyId: string,
markers: string[],
workspaceId: string,
): Promise<KbFixture> {
const fileIds: string[] = [];
for (const [markerIndex, marker] of markers.entries()) {
const filePath = writeMarkerFixture(journeyId, marker);
const uploadRun = await reporter.runStep(`upload doc ${markerIndex + 1}`, routes, [
"knowledge",
"doc",
"upload",
"--file",
filePath,
"--workspace-id",
workspaceId,
"--quiet",
]);
if (uploadRun.exitCode !== 0) {
throw new Error(`doc upload failed (marker ${marker}): ${uploadRun.stderr}`);
}
const fileId = uploadRun.stdout.trim();
reporter.trackResource("data-center-file", fileId);
fileIds.push(fileId);
}
const kbName = `e2e-${journeyId}-${Date.now() % 100000000}`;
const createRun = await reporter.runStep("kb create --wait", routes, [
"knowledge",
"create",
"--name",
kbName,
...fileIds.flatMap((fileId) => ["--doc-id", fileId]),
"--workspace-id",
workspaceId,
"--wait",
"--output",
"json",
]);
if (createRun.exitCode !== 0) {
throw new Error(`kb create failed: ${createRun.stderr}`);
}
const createData = parseStdoutJson<{
data?: { pipelineId?: string; ingestionId?: string };
final_status?: string;
}>(createRun.stdout);
const indexId = createData.data?.pipelineId ?? "";
const jobId = createData.data?.ingestionId ?? "";
if (!indexId) throw new Error(`kb create returned no pipelineId: ${createRun.stdout}`);
reporter.trackResource("kb", indexId);
reporter.recordNote(
`kb ${indexId} (${kbName}) import final_status=${createData.final_status ?? "?"}`,
);
return { indexId, jobId, fileIds };
}
/** Unified cleanup: kb delete (with retry) + data-center file delete. Never throws — failures surface in the report. */
export async function cleanupKbFixture(
reporter: JourneyReporter,
routes: E2eRouteExports,
fixture: Partial<KbFixture>,
workspaceId: string,
): Promise<void> {
if (fixture.indexId) {
const deleteRun = await deleteKbWithRetry(
(args) => reporter.runStep("cleanup: kb delete", routes, args),
fixture.indexId,
workspaceId,
);
if (deleteRun.exitCode === 0) reporter.markCleaned(fixture.indexId);
}
for (const fileId of fixture.fileIds ?? []) {
const fileDeleteRun = await reporter.runStep(`cleanup: file delete ${fileId}`, routes, [
"knowledge",
"file",
"delete",
"--file-id",
fileId,
"--yes",
"--workspace-id",
workspaceId,
]);
if (fileDeleteRun.exitCode === 0) reporter.markCleaned(fileId);
}
}
// ---- Backfill required retrieval parameters for search services (server gotcha) ----
// The minimal kb_search_configs created by service create --index-id is missing
// required fields like rerank_min_score / dense_similarity_top_k, so the search
// endpoint rejects it (InvalidParameter). The chat scene is unaffected.
// Workaround: get the beta config → backfill the fields → update --config-file.
interface AgentGetPayload {
data?: {
agent_details?: Array<{
agent_config?: { kb_search_configs?: Array<Record<string, unknown>> } & Record<
string,
unknown
>;
}>;
};
}
export async function patchSearchServiceRetrievalConfig(
reporter: JourneyReporter,
routes: E2eRouteExports,
agentId: string,
workspaceId: string,
): Promise<void> {
const getRun = await reporter.runStep("service get (patch retrieval config)", routes, [
"knowledge",
"service",
"get",
"--agent-id",
agentId,
"--agent-version",
"beta",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
if (getRun.exitCode !== 0) {
throw new Error(`service get failed while patching retrieval config: ${getRun.stderr}`);
}
const agentConfig = parseStdoutJson<AgentGetPayload>(getRun.stdout).data?.agent_details?.[0]
?.agent_config;
if (!agentConfig) throw new Error(`service get returned no agent_config: ${getRun.stdout}`);
for (const kbConfig of agentConfig.kb_search_configs ?? []) {
Object.assign(kbConfig, {
rerank_min_score: 0.01,
dense_similarity_top_k: 100,
sparse_similarity_top_k: 50,
enable_reranking: true,
rerank_top_n: 5,
});
}
const configDir = mkdtempSync(join(tmpdir(), "journey-svc-cfg-"));
const configFile = join(configDir, "agent-config.json");
writeFileSync(configFile, JSON.stringify(agentConfig));
const updateRun = await reporter.runStep("service update (patch retrieval config)", routes, [
"knowledge",
"service",
"update",
"--agent-id",
agentId,
"--config-file",
configFile,
"--workspace-id",
workspaceId,
]);
if (updateRun.exitCode !== 0) {
throw new Error(`service update failed while patching retrieval config: ${updateRun.stderr}`);
}
reporter.recordNote(
`service ${agentId} kb_search_configs patched with required retrieval parameters`,
);
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { KNOWLEDGE_CHAT_ROUTES } from "./topic-routes.ts";
import { isChatE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_CHAT_ROUTES } from "../topic-routes.ts";
interface ContentPart {
type: string;
@@ -17,6 +17,7 @@ interface DryRunBody {
parameters?: {
agent_options?: {
agent_id?: string;
agent_version?: string;
};
};
stream?: boolean;
@@ -61,7 +62,7 @@ describe("e2e: knowledge chat", () => {
test("缺少 --workspace-id 时非零退出并提示", async () => {
const { stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_CHAT_ROUTES,
// key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
// Fake key + isolated config dir: keep the local config's workspace_id/api_key from leaking in
[
"knowledge",
"chat",
@@ -101,6 +102,29 @@ describe("e2e: knowledge chat", () => {
expect(data.request?.input?.messages?.[0]?.role).toBe("user");
expect(data.request?.input?.messages?.[0]?.content).toBe("什么是RAG");
expect(data.request?.parameters?.agent_options?.agent_id).toBe("aid_test");
// Without --agent-version the field is not sent (default behavior unchanged)
expect(data.request?.parameters?.agent_options).not.toHaveProperty("agent_version");
});
test("--dry-run + --agent-version 落在 agent_options 内", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
"--message",
"什么是RAG",
"--agent-id",
"aid_test",
"--agent-version",
"2",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.parameters?.agent_options?.agent_version).toBe("2");
});
test("--dry-run 多轮消息解析 role:content 前缀", async () => {
@@ -193,6 +217,47 @@ describe("e2e: knowledge chat", () => {
image_url: { url: "https://example.com/b.png" },
});
});
test("--dry-run JSON 对象消息通道解析结构化 content", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
"--message",
'{"role":"user","content":[{"type":"text","text":"结构化消息"}]}',
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
const firstMsg = data.request?.input?.messages?.[0];
expect(firstMsg?.role).toBe("user");
expect(Array.isArray(firstMsg?.content)).toBe(true);
const parts = firstMsg?.content as ContentPart[];
expect(parts[0]).toEqual({ type: "text", text: "结构化消息" });
});
test("--image 与消息内嵌 image_url 冲突报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHAT_ROUTES, [
"knowledge",
"chat",
"--dry-run",
"--message",
'{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/e.png"}}]}',
"--image",
"https://example.com/x.png",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/embedded/i);
});
});
interface ChatJsonResult {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_DOC_DELETE_ROUTES } from "../topic-routes.ts";
describe("e2e: knowledge doc delete", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
"knowledge",
"doc",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--doc-id/i);
expect(stderr).toMatch(/--yes/i);
});
test("缺 --index-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
"knowledge",
"doc",
"delete",
"--doc-id",
"file_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("缺 --doc-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
"knowledge",
"doc",
"delete",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 doc_ids 数组 (snake_case)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
"knowledge",
"doc",
"delete",
"--index-id",
"idx_test",
"--doc-id",
"file_a",
"--doc-id",
"file_b",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string; request?: Record<string, unknown> }>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/delete_file/);
expect(data.request?.index_id).toBe("idx_test");
expect(data.request?.doc_ids).toEqual(["file_a", "file_b"]);
});
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
"knowledge",
"doc",
"delete",
"--index-id",
"idx_test",
"--doc-id",
"file_test",
"--api-key",
"sk-fake",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
});
});
// Live coverage comes from the kb delete chain (deleting a knowledge base deletes
// its documents); a standalone doc delete live case needs a "delete documents
// without deleting the base" scenario, covered by the chunk/category/file live chain.
@@ -0,0 +1,97 @@
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_DOC_LIST_ROUTES } from "../topic-routes.ts";
describe("e2e: knowledge doc list", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
"knowledge",
"doc",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--page-number/i);
expect(stderr).toMatch(/--page-size/i);
});
test("缺 --index-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
"knowledge",
"doc",
"list",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--page-size 101 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
"knowledge",
"doc",
"list",
"--index-id",
"idx_test",
"--page-size",
"101",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 query 参数名为 page_num (本接口坑位)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
"knowledge",
"doc",
"list",
"--dry-run",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
"--page-number",
"3",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string }>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/files/);
expect(data.endpoint).toMatch(/index_id=idx_test/);
expect(data.endpoint).toMatch(/page_num=3/);
expect(data.endpoint).not.toMatch(/page_number/);
});
});
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc list (live)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("对真实库列出文档 (可为空)", async () => {
const listRun = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
"knowledge",
"list",
"--workspace-id",
workspaceId,
"--quiet",
]);
const firstIndexId = listRun.stdout.trim().split("\n")[0];
if (!firstIndexId) return;
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_LIST_ROUTES, [
"knowledge",
"doc",
"list",
"--index-id",
firstIndexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ code: string }>(stdout);
expect(data.code).toBe("Success");
});
});
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_DOC_STATUS_ROUTES } from "../topic-routes.ts";
// Live coverage depends on a real job_id produced by doc upload; it is exercised
// as part of the upload live chain.
describe("e2e: knowledge doc status", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
"knowledge",
"doc",
"status",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--job-id/i);
expect(stderr).toMatch(/--wait/i);
expect(stderr).toMatch(/--poll-interval/i);
});
test("缺 --index-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
"knowledge",
"doc",
"status",
"--job-id",
"job_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("缺 --job-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
"knowledge",
"doc",
"status",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 query 同时含两个 id (双必填坑位)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
"knowledge",
"doc",
"status",
"--dry-run",
"--index-id",
"idx_test",
"--job-id",
"job_test",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string }>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index_job\/status/);
expect(data.endpoint).toMatch(/index_id=idx_test/);
expect(data.endpoint).toMatch(/job_id=job_test/);
});
test("--dry-run 断言分页参数 query 透传", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_STATUS_ROUTES, [
"knowledge",
"doc",
"status",
"--dry-run",
"--index-id",
"idx_test",
"--job-id",
"job_test",
"--page-number",
"2",
"--page-size",
"50",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string }>(stdout);
expect(data.endpoint).toMatch(/page_number=2/);
expect(data.endpoint).toMatch(/page_size=50/);
});
});
@@ -0,0 +1,254 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_DOC_TAG_ROUTES } from "../topic-routes.ts";
describe("e2e: knowledge doc tag", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--doc-id/i);
expect(stderr).toMatch(/--tag/i);
expect(stderr).toMatch(/--mode/i);
});
test("缺 --doc-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--tag",
"demo",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("缺 --tag 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("21 个 --doc-id 报 USAGE (2)", async () => {
const docIdArgs = Array.from({ length: 21 }, (_, docIndex) => [
"--doc-id",
`file_${docIndex}`,
]).flat();
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
...docIdArgs,
"--tag",
"demo",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("33 字符标签报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_test",
"--tag",
"x".repeat(33),
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("101 个标签报 USAGE (2)", async () => {
const tagArgs = Array.from({ length: 101 }, (_, tagIndex) => ["--tag", `t${tagIndex}`]).flat();
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_test",
...tagArgs,
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("标签总长超 700 字符报 USAGE (2)", async () => {
// 25 tags × 32 chars = 800 > 700, while each tag and the count stay within limits
const tagArgs = Array.from({ length: 25 }, () => ["--tag", "x".repeat(32)]).flat();
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_test",
...tagArgs,
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--mode 非法值报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_test",
"--tag",
"demo",
"--mode",
"replace",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 updateMode 大写映射与 fileInfos 结构", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_a",
"--doc-id",
"file_b",
"--tag",
"alpha",
"--tag",
"beta",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{
endpoint?: string;
request?: { fileInfos?: Array<{ fileId: string; tags: string[] }>; updateMode?: string };
}>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/connector\/dash\/batchUpdateFileTag/);
expect(data.request?.updateMode).toBe("APPEND");
expect(data.request?.fileInfos).toEqual([
{ fileId: "file_a", tags: ["alpha", "beta"] },
{ fileId: "file_b", tags: ["alpha", "beta"] },
]);
});
test("--dry-run 断言 --mode overwrite 大写映射 OVERWRITE", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
"file_test",
"--tag",
"final",
"--mode",
"overwrite",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: { updateMode?: string } }>(stdout);
expect(data.request?.updateMode).toBe("OVERWRITE");
});
});
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc tag (live)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("对新上传文件打标断言 Success", async () => {
const fixtureDir = mkdtempSync(join(tmpdir(), "doc-tag-e2e-"));
const filePath = join(fixtureDir, `tag-${Date.now()}.md`);
writeFileSync(filePath, "# doc tag e2e fixture\n");
const uploadRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
filePath,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(uploadRun.exitCode, uploadRun.stderr).toBe(0);
const fileId = uploadRun.stdout.trim();
const tagRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
fileId,
"--tag",
"e2e-tag",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(tagRun.exitCode, tagRun.stderr).toBe(0);
const data = parseStdoutJson<{ code: string }>(tagRun.stdout);
expect(data.code).toBe("Success");
// overwrite mode replaces the tag set live (append above covered the default)
const overwriteRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"doc",
"tag",
"--doc-id",
fileId,
"--tag",
"e2e-tag-final",
"--mode",
"overwrite",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(overwriteRun.exitCode, overwriteRun.stderr).toBe(0);
const overwriteData = parseStdoutJson<{ code: string }>(overwriteRun.stdout);
expect(overwriteData.code).toBe("Success");
// Clean up the uploaded data-center file
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"file",
"delete",
"--file-id",
fileId,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
}, 120_000);
});
@@ -0,0 +1,189 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_DOC_UPLOAD_ROUTES } from "../topic-routes.ts";
const fixtureDir = mkdtempSync(join(tmpdir(), "doc-upload-e2e-"));
const smallMd = join(fixtureDir, "small.md");
writeFileSync(smallMd, "# e2e fixture\n");
interface DryRunStep {
step: string;
endpoint: string;
request: unknown;
}
describe("e2e: knowledge doc upload", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--file/i);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--category-id/i);
expect(stderr).toMatch(/--tag/i);
expect(stderr).toMatch(/--wait/i);
});
test("缺 --file 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--wait 无 --index-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
smallMd,
"--wait",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("文件不存在非零退出且 hint 含 errno", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
join(fixtureDir, "missing.md"),
"--workspace-id",
"ws_test",
"--dry-run",
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/ENOENT|Cannot read file/i);
});
test(".zip 扩展名报 USAGE 并列出支持格式", async () => {
const zipPath = join(fixtureDir, "bad.zip");
writeFileSync(zipPath, "PK");
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
zipPath,
"--workspace-id",
"ws_test",
"--dry-run",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/\.pdf/);
});
test("--dry-run 不带 --index-id 输出 3 步编排计划", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
smallMd,
"--category-id",
"cate_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ steps: DryRunStep[] }>(stdout);
expect(data.steps).toHaveLength(3);
const leaseRequest = data.steps[0]!.request as { sizeBytes?: unknown; category?: string };
expect(typeof leaseRequest.sizeBytes).toBe("string"); // gotcha: sizeBytes must be a string
expect(leaseRequest.category).toBe("cate_test");
});
test("--dry-run 带 --index-id 输出 4 步且 job 请求含显式 sourceType", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
smallMd,
"--category-id",
"cate_test",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ steps: DryRunStep[] }>(stdout);
expect(data.steps).toHaveLength(4);
const jobRequest = data.steps[3]!.request as {
indexId?: string;
dataSource?: { sourceType?: string; fileIds?: string[] };
};
// Live-verified gotcha: job/create requires the nested dataSource shape, and
// omitting sourceType would import the entire data center
expect(jobRequest.dataSource?.sourceType).toBe("DATA_CENTER_FILE");
expect(jobRequest.indexId).toBe("idx_test");
expect(jobRequest).not.toHaveProperty("documentIds");
});
});
// Live write artifacts (data-center files) are cleaned up in place via the file delete command.
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc upload (live)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("上传 2 个 md 返回各自 file_ 前缀 fileId (多文件 happy path)", async () => {
const livePathA = join(fixtureDir, `live-a-${Date.now()}.md`);
const livePathB = join(fixtureDir, `live-b-${Date.now()}.md`);
writeFileSync(livePathA, "# e2e live upload fixture A\n");
writeFileSync(livePathB, "# e2e live upload fixture B\n");
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
livePathA,
"--file",
livePathB,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(exitCode, stderr).toBe(0);
const fileIds = stdout.trim().split("\n").filter(Boolean);
expect(fileIds).toHaveLength(2);
for (const fileId of fileIds) {
expect(fileId).toMatch(/^file_/);
}
// Clean up both uploaded data-center files
for (const fileId of fileIds) {
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
"knowledge",
"file",
"delete",
"--file-id",
fileId,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
}
}, 120_000);
});
@@ -0,0 +1,156 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_KB_CREATE_ROUTES } from "../topic-routes.ts";
interface DryRunBody {
endpoint?: string;
request?: {
sourceType?: string;
sinkType?: string;
docIds?: string[];
categoryIds?: string[];
embeddingModelName?: string;
chunkSize?: number;
};
}
describe("e2e: knowledge kb create", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name/i);
expect(stderr).toMatch(/--doc-id/i);
expect(stderr).toMatch(/--category-id/i);
expect(stderr).toMatch(/--embedding-model/i);
expect(stderr).toMatch(/--chunk-size/i);
});
test("缺 --name 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--doc-id",
"file_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("无数据源 flag 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--name",
"demo",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("两数据源同传报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--name",
"demo",
"--doc-id",
"file_test",
"--category-id",
"cate_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--name 21 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--name",
"x".repeat(21),
"--doc-id",
"file_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run + --doc-id 断言 DATA_CENTER_FILE / docIds / BUILT_IN", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--name",
"demo",
"--doc-id",
"file_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/create_v2/);
expect(data.request?.sourceType).toBe("DATA_CENTER_FILE");
expect(data.request?.docIds).toEqual(["file_test"]);
expect(data.request?.sinkType).toBe("BUILT_IN");
// Defaults are part of the contract — the server applies no fallback of its own
expect(data.request?.embeddingModelName).toBe("text-embedding-v4");
expect(data.request?.chunkSize).toBe(600);
});
test("--dry-run 断言 --embedding-model/--chunk-size 在 body 中的映射", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--name",
"demo",
"--doc-id",
"file_test",
"--embedding-model",
"text-embedding-v3",
"--chunk-size",
"300",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.embeddingModelName).toBe("text-embedding-v3");
expect(data.request?.chunkSize).toBe(300);
});
test("--dry-run + --category-id 断言 DATA_CENTER_CATEGORY / categoryIds", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_CREATE_ROUTES, [
"knowledge",
"create",
"--name",
"demo",
"--category-id",
"cate_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.sourceType).toBe("DATA_CENTER_CATEGORY");
expect(data.request?.categoryIds).toEqual(["cate_test"]);
});
});
// The live self-cleaning chain lives in knowledge-kb-delete.e2e.test.ts (upload → create → delete).
@@ -0,0 +1,220 @@
// The live group chains the full lifecycle: upload → create → update → upload --index-id
// (import orchestration) → delete → list to verify removal.
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { deleteKbWithRetry } from "./journeys/journey-helpers.ts";
import { KNOWLEDGE_KB_DELETE_ROUTES } from "../topic-routes.ts";
describe("e2e: knowledge kb delete", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"delete",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--yes/i);
});
test("缺 --index-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"delete",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 body index_id (snake_case)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"delete",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string; request?: Record<string, unknown> }>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/delete/);
expect(data.request?.index_id).toBe("idx_test");
});
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"delete",
"--index-id",
"idx_test",
"--api-key",
"sk-fake",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
});
});
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("upload → create → update → upload --index-id → delete → list 验证消失", async () => {
// Mid-chain failures must not leak the base / data-center files — track created
// resources and best-effort clean them in finally (only when the chain aborted)
const fixtureFileIds: string[] = [];
let indexId = "";
let chainCompleted = false;
try {
// 1) Upload a file
const fixtureDir = mkdtempSync(join(tmpdir(), "kb-chain-e2e-"));
const filePath = join(fixtureDir, `chain-${Date.now()}.md`);
writeFileSync(filePath, "# kb chain e2e fixture\n");
const uploadRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
filePath,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(uploadRun.exitCode, uploadRun.stderr).toBe(0);
const fileId = uploadRun.stdout.trim();
expect(fileId).toMatch(/^file_/);
fixtureFileIds.push(fileId);
// 2) Create the knowledge base (--wait for the import; server gotcha: a freshly
// created base cannot be deleted immediately — Index.IndexStatusError)
const kbName = `e2e-del-${Date.now() % 100000000}`;
const createRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"create",
"--name",
kbName,
"--doc-id",
fileId,
"--workspace-id",
workspaceId,
"--wait",
"--quiet",
]);
expect(createRun.exitCode, createRun.stderr).toBe(0);
indexId = createRun.stdout.trim().split("\n")[0]!;
expect(indexId).toBeTruthy();
// 2.5) Live update coverage: name / description / rerank threshold in one call
const updateRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"update",
"--index-id",
indexId,
"--name",
`e2e-upd-${Date.now() % 100000000}`,
"--description",
"e2e chain updated description",
"--rerank-min-score",
"0.3",
"--workspace-id",
workspaceId,
]);
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
expect(updateRun.stdout).toMatch(/updated/);
// 2.7) Live coverage of the upload → import orchestration (doc upload --index-id --wait):
// the journeys always upload bare files and import via kb create, so this is the
// only place the createImportJob step runs live
const importFilePath = join(fixtureDir, `chain-import-${Date.now()}.md`);
writeFileSync(importFilePath, "# kb chain e2e import fixture\n");
const importUploadRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"doc",
"upload",
"--file",
importFilePath,
"--index-id",
indexId,
"--wait",
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(importUploadRun.exitCode, importUploadRun.stderr).toBe(0);
const importedFileId = importUploadRun.stdout.trim();
expect(importedFileId).toMatch(/^file_/);
fixtureFileIds.push(importedFileId);
// 3) Delete the base (--yes non-interactive; deleteKbWithRetry retries on
// IndexStatusError: readiness can lag briefly even after the import completes)
const deleteRun = await deleteKbWithRetry(
(args) => runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, args),
indexId,
workspaceId,
);
expect(deleteRun.exitCode, deleteRun.stderr).toBe(0);
expect(deleteRun.stdout).toMatch(/deleted/);
// 3.5) Clean up the data-center files (kb delete removes them from the base but
// the file artifacts persist in the data center)
for (const cleanupFileId of fixtureFileIds) {
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"file",
"delete",
"--file-id",
cleanupFileId,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(fileDeleteRun.exitCode, fileDeleteRun.stderr).toBe(0);
}
// 4) list verifies the base is gone
const listRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"list",
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(listRun.exitCode, listRun.stderr).toBe(0);
const remainingIds = listRun.stdout.trim().split("\n");
expect(remainingIds).not.toContain(indexId);
chainCompleted = true;
} finally {
if (!chainCompleted) {
// Best-effort cleanup on abort — do not assert, the original failure matters more
if (indexId) {
await deleteKbWithRetry(
(args) => runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, args),
indexId,
workspaceId,
);
}
for (const cleanupFileId of fixtureFileIds) {
await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"file",
"delete",
"--file-id",
cleanupFileId,
"--yes",
"--workspace-id",
workspaceId,
]);
}
}
}
// Two --wait import phases in the chain — timeout sized for slow server-side parsing
}, 600_000);
});
@@ -0,0 +1,92 @@
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_KB_INFO_ROUTES } from "../topic-routes.ts";
describe("e2e: knowledge kb info", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
"knowledge",
"info",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 --index-id 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
"knowledge",
"info",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--index-id|Usage:/i);
});
test("--dry-run 输出 list endpoint 与兜底策略说明", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
"knowledge",
"info",
"--dry-run",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string; strategy?: string }>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/list/);
expect(data.endpoint).toMatch(/page_size=100/);
expect(data.strategy).toMatch(/paginate/i);
});
});
interface KbListQuietResult {
stdout: string;
}
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb info (live)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("先 list 取 id 再 info 断言分组字段", async () => {
const listRun: KbListQuietResult = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
"knowledge",
"list",
"--workspace-id",
workspaceId,
"--quiet",
]);
const firstId = listRun.stdout.trim().split("\n")[0];
if (!firstId) return; // skip assertions when the workspace has no knowledge bases
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
"knowledge",
"info",
"--index-id",
firstId,
"--workspace-id",
workspaceId,
]);
expect(exitCode, stderr).toBe(0);
expect(stdout).toMatch(/Basic:/);
expect(stdout).toMatch(/Indexing:/);
expect(stdout).toMatch(/immutable/);
expect(stdout).toMatch(/Retrieval:/);
});
test("不存在的 id 非零退出并给 hint", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_INFO_ROUTES, [
"knowledge",
"info",
"--index-id",
"idx-not-exist-e2e",
"--workspace-id",
workspaceId,
]);
expect(exitCode).not.toBe(0);
expect(stderr).toMatch(/not found/i);
});
});
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_KB_LIST_ROUTES } from "../topic-routes.ts";
interface DryRunBody {
endpoint?: string;
request?: unknown;
}
describe("e2e: knowledge kb list", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--name/i);
expect(stderr).toMatch(/--page-number/i);
expect(stderr).toMatch(/--page-size/i);
expect(stderr).toMatch(/--workspace-id/i);
});
test("缺 workspace 时报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_KB_LIST_ROUTES,
["knowledge", "list", "--api-key", "sk-fake", "--output", "json"],
{ BAILIAN_WORKSPACE_ID: "", BAILIAN_CONFIG_DIR: "/tmp" },
);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/workspace.*required/i);
});
test("--dry-run 输出 endpoint (query string 含分页/过滤参数)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--dry-run",
"--workspace-id",
"ws_test",
"--name",
"demo",
"--page-number",
"2",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/ws_test\.cn-beijing\.maas\.aliyuncs\.com/);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/list/);
expect(data.endpoint).toMatch(/page_number=2/);
expect(data.endpoint).toMatch(/pipeline_name=demo/);
expect(data.request).toBeNull();
});
test("--name 21 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--dry-run",
"--workspace-id",
"ws_test",
"--name",
"x".repeat(21),
]);
expect(exitCode).toBe(2);
});
test("--page-size 101 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--dry-run",
"--workspace-id",
"ws_test",
"--page-size",
"101",
]);
expect(exitCode).toBe(2);
});
});
interface KbListResponse {
code: string;
data: { rows: Array<{ id: string; name: string }> };
}
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb list (live)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("list 返回 rows 数组", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<KbListResponse>(stdout);
expect(data.code).toBe("Success");
expect(Array.isArray(data.data.rows)).toBe(true);
});
test("--quiet 输出行数与 rows 数一致", async () => {
const jsonRun = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
const rows = parseStdoutJson<KbListResponse>(jsonRun.stdout).data.rows;
const quietRun = await runCommandE2e(KNOWLEDGE_KB_LIST_ROUTES, [
"knowledge",
"list",
"--workspace-id",
workspaceId,
"--quiet",
]);
const lines = quietRun.stdout.trim().split("\n").filter(Boolean);
expect(lines.length).toBe(rows.length);
});
});
@@ -0,0 +1,115 @@
import { describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_KB_UPDATE_ROUTES } from "../topic-routes.ts";
describe("e2e: knowledge kb update", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--index-id/i);
expect(stderr).toMatch(/--name/i);
expect(stderr).toMatch(/--description/i);
expect(stderr).toMatch(/--rerank-min-score/i);
});
test("缺 --index-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--description",
"x",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("无修改项报 USAGE (2) 'Nothing to update'", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/nothing to update/i);
});
test("--rerank-min-score 1.5 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--index-id",
"idx_test",
"--rerank-min-score",
"1.5",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--name 21 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--index-id",
"idx_test",
"--name",
"x".repeat(21),
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 body 键名为 id (本接口坑位)", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--index-id",
"idx_test",
"--description",
"new desc",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ endpoint?: string; request?: Record<string, unknown> }>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/index\/update/);
expect(data.request?.id).toBe("idx_test");
expect(data.request).not.toHaveProperty("index_id");
expect(data.request?.description).toBe("new desc");
});
test("--dry-run 断言 rerankMinScore body 键名映射", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_UPDATE_ROUTES, [
"knowledge",
"update",
"--index-id",
"idx_test",
"--rerank-min-score",
"0.3",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<{ request?: Record<string, unknown> }>(stdout);
expect(data.request?.rerankMinScore).toBe(0.3);
});
});
// Live coverage: the kb delete self-cleaning chain (knowledge-kb-delete.e2e.test.ts)
// runs a live update step between create and delete.
@@ -1,12 +1,13 @@
import { describe, expect, test } from "vite-plus/test";
import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { KNOWLEDGE_SEARCH_ROUTES } from "./topic-routes.ts";
import { isSearchE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_SEARCH_ROUTES } from "../topic-routes.ts";
interface DryRunBody {
endpoint?: string;
request?: {
query?: string;
agent_id?: string;
agent_version?: string;
images?: string[];
query_history?: Array<{ role: string; content: string }>;
};
@@ -52,7 +53,7 @@ describe("e2e: knowledge search", () => {
test("缺少 --workspace-id 时非零退出并提示", async () => {
const { stderr, exitCode } = await runCommandE2e(
KNOWLEDGE_SEARCH_ROUTES,
// key + 隔离配置目录:避免本机 config 的 workspace_id/api_key 漏入
// Fake key + isolated config dir: keep the local config's workspace_id/api_key from leaking in
[
"knowledge",
"search",
@@ -91,6 +92,29 @@ describe("e2e: knowledge search", () => {
expect(data.endpoint).toMatch(/api\/v1\/indices\/knowledge\/search/);
expect(data.request?.query).toBe("什么是RAG");
expect(data.request?.agent_id).toBe("aid_test");
// Without --agent-version the field is not sent (default behavior unchanged: latest published version)
expect(data.request).not.toHaveProperty("agent_version");
});
test("--dry-run + --agent-version 落在 body 顶层", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SEARCH_ROUTES, [
"knowledge",
"search",
"--dry-run",
"--query",
"什么是RAG",
"--agent-id",
"aid_test",
"--agent-version",
"beta",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.agent_version).toBe("beta");
});
test("--dry-run + --image 输出 images", async () => {
@@ -0,0 +1,651 @@
// service group, 7 commands: the static group covers help/missing-args/dry-run per
// command; the live group runs the full lifecycle (create → scalar update → get
// assertion → search --agent-version beta smoke → deploy → copy → delete×2).
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { isKbAdminE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_SERVICE_ROUTES } from "../topic-routes.ts";
interface DryRunBody {
endpoint?: string;
request?: Record<string, unknown>;
}
describe("e2e: knowledge service list", () => {
test("--help 展示 flags", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--help",
]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--scene/i);
expect(stderr).toMatch(/--status/i);
expect(stderr).toMatch(/--index-id/i);
});
test("缺 --scene 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--scene 非法值报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"qa",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--status 非法值报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"chat",
"--status",
"online",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--page-size 101 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"chat",
"--page-size",
"101",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--dry-run 断言 body agent_scene 与分页走 body", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"chat",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/api\/v1\/indices\/rag\/app\/list/);
expect(data.request?.agent_scene).toBe("chat");
expect(data.request?.pipeline_id).toBe("idx_test");
expect(data.request?.page_number).toBe(1);
});
test("--dry-run 断言 --status/--name/--agent-id 的 snake_case body 映射", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"search",
"--status",
"deployed",
"--name",
"demo",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.request?.agent_status).toBe("deployed");
expect(data.request?.agent_name).toBe("demo");
expect(data.request?.agent_id).toBe("aid_test");
});
});
describe("e2e: knowledge service get / create / copy", () => {
test("get: 缺 --agent-id 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"get",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("get: --dry-run 断言 agent_version 透传", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"get",
"--agent-id",
"aid_test",
"--agent-version",
"beta",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/rag\/app\/get/);
expect(data.request?.agent_version).toBe("beta");
});
test("create: 缺 --scene 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"create",
"--name",
"demo",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("create: --scene 非法值报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"create",
"--name",
"demo",
"--scene",
"qa",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("create: --name 201 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"create",
"--name",
"x".repeat(201),
"--scene",
"chat",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("create: --description 1001 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"create",
"--name",
"demo",
"--scene",
"chat",
"--description",
"x".repeat(1001),
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("create: --dry-run + --index-id 断言最简 kb_search_configs", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"create",
"--name",
"demo",
"--scene",
"search",
"--index-id",
"idx_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
const agentConfig = data.request?.agent_config as
| { kb_search_configs?: Array<{ id: string }> }
| undefined;
expect(agentConfig?.kb_search_configs).toEqual([{ id: "idx_test" }]);
});
test("copy: --dry-run 断言 body agent_id", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"copy",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/rag\/app\/copy/);
expect(data.request?.agent_id).toBe("aid_test");
});
});
describe("e2e: knowledge service update", () => {
test("无修改项报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/nothing to update/i);
});
test("--config-file 与标量 config flag 互斥 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--temperature",
"0.5",
"--config-file",
"/tmp/whatever.json",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("已发布版本 + config 修改前置拦截 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--agent-version",
"1",
"--temperature",
"0.5",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/beta draft/i);
});
test("--temperature 2.5 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--temperature",
"2.5",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--policy 非法值报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--policy",
"fast",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--max-llm-calls 31 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--max-llm-calls",
"31",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--enable-session-file 非 true/false 报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--enable-session-file",
"maybe",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--name 201 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--name",
"x".repeat(201),
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--description 1001 字符报 USAGE (2)", async () => {
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--description",
"x".repeat(1001),
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
});
test("--config-file 非法 JSON 报 USAGE (2)", async () => {
const fixtureDir = mkdtempSync(join(tmpdir(), "svc-update-e2e-"));
const badJson = join(fixtureDir, "bad.json");
writeFileSync(badJson, "{not json");
const { exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--config-file",
badJson,
"--workspace-id",
"ws_test",
"--dry-run",
]);
expect(exitCode).toBe(2);
});
test("--dry-run + --version-desc 断言 body 与标量合并说明", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
"aid_test",
"--temperature",
"0.7",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody & { note?: string }>(stdout);
expect(data.endpoint).toMatch(/rag\/app\/update/);
const agentConfig = data.request?.agent_config as { temperature?: number } | undefined;
expect(agentConfig?.temperature).toBe(0.7);
expect(data.note).toMatch(/merged/i);
});
});
describe("e2e: knowledge service deploy / delete (危险)", () => {
test("deploy: 非 TTY 无 --yes 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"deploy",
"--agent-id",
"aid_test",
"--api-key",
"sk-fake",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
});
test("deploy: --dry-run 断言 body", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"deploy",
"--agent-id",
"aid_test",
"--version-desc",
"v1 desc",
"--workspace-id",
"ws_test",
"--dry-run",
"--output",
"json",
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/rag\/app\/deploy/);
expect(data.request?.agent_version_desc).toBe("v1 desc");
});
test("delete: 非 TTY 无 --yes 报 USAGE (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"delete",
"--agent-id",
"aid_test",
"--api-key",
"sk-fake",
"--workspace-id",
"ws_test",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
});
});
// Path gotcha: the actual gateway prefix is rag/app/; the rag/agent/ prefix from the
// public API docs is not routed (it returns console HTML).
describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live, 自清理)", () => {
const workspaceId = process.env.BAILIAN_WORKSPACE_ID!;
test("create → update → get 断言 → deploy → copy → delete×2", async () => {
const serviceName = `e2e-svc-${Date.now() % 100000000}`;
// 1) create (chat scene, no knowledge base bound — verifies server defaults)
const createRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"create",
"--name",
serviceName,
"--scene",
"chat",
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(createRun.exitCode, createRun.stderr).toBe(0);
const agentId = createRun.stdout.trim().split("\n").pop()!;
expect(agentId).toMatch(/^aid-/);
// 1.5) list asserts the new agent shows up (filtered by agent_id)
const listRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"chat",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(listRun.exitCode, listRun.stderr).toBe(0);
expect(listRun.stdout.trim().split("\n")).toContain(agentId);
// 2) scalar update (read-merge-write onto the beta draft)
const updateRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"update",
"--agent-id",
agentId,
"--temperature",
"0.55",
"--workspace-id",
workspaceId,
]);
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
// 3) get asserts the scalars landed on beta
const getRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"get",
"--agent-id",
agentId,
"--agent-version",
"beta",
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(getRun.exitCode, getRun.stderr).toBe(0);
const getData = parseStdoutJson<{
data: { agent_details: Array<{ agent_config?: { temperature?: number } }> };
}>(getRun.stdout);
expect(getData.data.agent_details[0]?.agent_config?.temperature).toBe(0.55);
// 4) deploy (--yes non-interactive)
const deployRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"deploy",
"--agent-id",
agentId,
"--yes",
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(deployRun.exitCode, deployRun.stderr).toBe(0);
expect(deployRun.stdout.trim().split("\n").pop()).toBe("1");
// 4.5) bare get (no --agent-version) returns all versions: beta draft + published 1
const allVersionsRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"get",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(allVersionsRun.exitCode, allVersionsRun.stderr).toBe(0);
const allVersionsData = parseStdoutJson<{
data: { agent_details: Array<{ agent_version?: string }> };
}>(allVersionsRun.stdout);
const versions = allVersionsData.data.agent_details.map((detail) => detail.agent_version);
expect(versions).toContain("beta");
expect(versions).toContain("1");
// 5) copy
const copyRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"copy",
"--agent-id",
agentId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(copyRun.exitCode, copyRun.stderr).toBe(0);
const copiedAgentId = copyRun.stdout.trim().split("\n").pop()!;
expect(copiedAgentId).toMatch(/^aid-/);
expect(copiedAgentId).not.toBe(agentId);
// 6) delete both (idempotent soft delete)
for (const idToDelete of [copiedAgentId, agentId]) {
const deleteRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"delete",
"--agent-id",
idToDelete,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(deleteRun.exitCode, deleteRun.stderr).toBe(0);
}
}, 300_000);
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "./helpers.ts";
import { KNOWLEDGE_ROUTES } from "./topic-routes.ts";
import { isDashScopeE2EReady, parseStdoutJson, runCommandE2e } from "../helpers.ts";
import { KNOWLEDGE_ROUTES } from "../topic-routes.ts";
// ---- Types ----
+139
View File
@@ -183,3 +183,142 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
"managed-agent session send": "managedAgentSessionSend",
"managed-agent skill-list": "managedAgentSkillList",
};
export const KNOWLEDGE_KB_LIST_ROUTES: E2eRouteExports = {
"knowledge list": "knowledgeKbList",
};
export const KNOWLEDGE_KB_INFO_ROUTES: E2eRouteExports = {
"knowledge info": "knowledgeKbInfo",
"knowledge list": "knowledgeKbList", // live cases list first to grab a real id
};
export const KNOWLEDGE_DOC_LIST_ROUTES: E2eRouteExports = {
"knowledge doc list": "knowledgeDocList",
"knowledge list": "knowledgeKbList", // live cases grab a real index id first
};
export const KNOWLEDGE_DOC_STATUS_ROUTES: E2eRouteExports = {
"knowledge doc status": "knowledgeDocStatus",
};
export const KNOWLEDGE_DOC_UPLOAD_ROUTES: E2eRouteExports = {
"knowledge doc upload": "knowledgeDocUpload",
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
};
export const KNOWLEDGE_KB_CREATE_ROUTES: E2eRouteExports = {
"knowledge create": "knowledgeKbCreate",
};
export const KNOWLEDGE_KB_UPDATE_ROUTES: E2eRouteExports = {
"knowledge update": "knowledgeKbUpdate",
};
export const KNOWLEDGE_KB_DELETE_ROUTES: E2eRouteExports = {
"knowledge delete": "knowledgeKbDelete",
"knowledge create": "knowledgeKbCreate", // live self-cleaning chain
"knowledge update": "knowledgeKbUpdate", // live update step in the chain
"knowledge list": "knowledgeKbList",
"knowledge doc upload": "knowledgeDocUpload",
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
};
export const KNOWLEDGE_DOC_DELETE_ROUTES: E2eRouteExports = {
"knowledge doc delete": "knowledgeDocDelete",
};
export const KNOWLEDGE_DOC_TAG_ROUTES: E2eRouteExports = {
"knowledge doc tag": "knowledgeDocTag",
"knowledge doc upload": "knowledgeDocUpload", // live uploads first to grab a real fileId
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
};
export const KNOWLEDGE_SERVICE_ROUTES: E2eRouteExports = {
"knowledge service list": "knowledgeServiceList",
"knowledge service get": "knowledgeServiceGet",
"knowledge service create": "knowledgeServiceCreate",
"knowledge service update": "knowledgeServiceUpdate",
"knowledge service deploy": "knowledgeServiceDeploy",
"knowledge service delete": "knowledgeServiceDelete",
"knowledge service copy": "knowledgeServiceCopy",
"knowledge search": "knowledgeSearch", // beta verification chain smoke
};
export const KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES: E2eRouteExports = {
"knowledge chunk add": "knowledgeChunkAdd",
"knowledge chunk list": "knowledgeChunkList",
"knowledge chunk update": "knowledgeChunkUpdate",
"knowledge chunk delete": "knowledgeChunkDelete",
"knowledge stats": "knowledgeKbStats",
"knowledge retrieve": "knowledgeRetrieve", // live rerank retrieval on the throwaway base
"knowledge category list": "knowledgeCategoryList",
"knowledge category add": "knowledgeCategoryAdd",
"knowledge category delete": "knowledgeCategoryDelete",
"knowledge file list": "knowledgeFileList",
"knowledge file get": "knowledgeFileGet",
"knowledge file delete": "knowledgeFileDelete",
"knowledge collection create": "knowledgeCollectionCreate",
"knowledge collection get": "knowledgeCollectionGet",
"knowledge doc import-oss": "knowledgeDocImportOss",
"knowledge list": "knowledgeKbList", // live grabs a real index id
"knowledge doc upload": "knowledgeDocUpload", // live produces a fileId
"knowledge doc delete": "knowledgeDocDelete", // live verifies document-level delete semantics
};
// ---- Journey-level routes (full user-journey chains, see journeys/README.md) ----
/** Minimal create/cleanup routes shared by J1J4 */
const JOURNEY_KB_BASE_ROUTES: E2eRouteExports = {
"knowledge doc upload": "knowledgeDocUpload",
"knowledge create": "knowledgeKbCreate",
"knowledge retrieve": "knowledgeRetrieve",
"knowledge delete": "knowledgeKbDelete",
"knowledge file delete": "knowledgeFileDelete", // clean up data-center files
};
export const JOURNEY_J1_ROUTES: E2eRouteExports = {
...JOURNEY_KB_BASE_ROUTES,
"knowledge service create": "knowledgeServiceCreate",
"knowledge service get": "knowledgeServiceGet", // search-service retrieval-parameter backfill chain
"knowledge service update": "knowledgeServiceUpdate",
"knowledge service delete": "knowledgeServiceDelete",
"knowledge search": "knowledgeSearch",
"knowledge chat": "knowledgeChat",
};
export const JOURNEY_J2_ROUTES: E2eRouteExports = {
...JOURNEY_KB_BASE_ROUTES,
"knowledge doc list": "knowledgeDocList",
"knowledge doc status": "knowledgeDocStatus",
"knowledge doc tag": "knowledgeDocTag",
"knowledge doc delete": "knowledgeDocDelete",
};
export const JOURNEY_J3_ROUTES: E2eRouteExports = {
...JOURNEY_KB_BASE_ROUTES,
"knowledge chunk list": "knowledgeChunkList",
"knowledge chunk update": "knowledgeChunkUpdate",
};
export const JOURNEY_J4_ROUTES: E2eRouteExports = {
...JOURNEY_KB_BASE_ROUTES,
"knowledge service create": "knowledgeServiceCreate",
"knowledge service update": "knowledgeServiceUpdate",
"knowledge service get": "knowledgeServiceGet",
"knowledge service deploy": "knowledgeServiceDeploy",
"knowledge service delete": "knowledgeServiceDelete",
"knowledge search": "knowledgeSearch",
};
export const JOURNEY_J5_ROUTES: E2eRouteExports = {
"knowledge collection create": "knowledgeCollectionCreate",
"knowledge collection get": "knowledgeCollectionGet",
"knowledge category add": "knowledgeCategoryAdd",
"knowledge category list": "knowledgeCategoryList",
"knowledge category delete": "knowledgeCategoryDelete",
"knowledge file list": "knowledgeFileList",
"knowledge file get": "knowledgeFileGet",
"knowledge file delete": "knowledgeFileDelete",
"knowledge doc upload": "knowledgeDocUpload",
};
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vite-plus/test";
import { buildDataSourceFields } from "../../src/commands/knowledge/kb-create.ts";
describe("buildDataSourceFields", () => {
test("--doc-id 推导 DATA_CENTER_FILE + docIds", () => {
const fields = buildDataSourceFields({ docId: ["file_1", "file_2"] });
expect(fields).toEqual({
sourceType: "DATA_CENTER_FILE",
docIds: ["file_1", "file_2"],
dataSources: [{ sourceType: "DATA_CENTER_FILE" }],
});
});
test("--category-id 推导 DATA_CENTER_CATEGORY + categoryIds", () => {
const fields = buildDataSourceFields({ categoryId: ["cate_1"] });
expect(fields).toEqual({
sourceType: "DATA_CENTER_CATEGORY",
categoryIds: ["cate_1"],
dataSources: [{ sourceType: "DATA_CENTER_CATEGORY" }],
});
});
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vite-plus/test";
import { ExitCode } from "bailian-cli-core";
import { resolveWorkspaceId, truncateLine } from "../../src/commands/knowledge/shared.ts";
const identity = { binName: "kscli" };
describe("resolveWorkspaceId", () => {
test("flag 优先于 settings", () => {
const workspaceId = resolveWorkspaceId({
flags: { workspaceId: "ws_flag" },
settings: { workspaceId: "ws_cfg" },
identity,
});
expect(workspaceId).toBe("ws_flag");
});
test("无 flag 时回退 settings (env/config 已并入 settings)", () => {
const workspaceId = resolveWorkspaceId({
flags: {},
settings: { workspaceId: "ws_cfg" },
identity,
});
expect(workspaceId).toBe("ws_cfg");
});
test("均缺失时抛 USAGE 且 hint 含 binName", () => {
try {
resolveWorkspaceId({ flags: {}, settings: {}, identity });
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
expect((error as { hint?: string }).hint).toMatch(/kscli config set workspace_id/);
}
});
});
describe("truncateLine", () => {
test("非 TTY 下不截断", () => {
const longLine = "x".repeat(500);
expect(truncateLine(longLine)).toBe(longLine);
});
});
@@ -0,0 +1,62 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { ExitCode } from "bailian-cli-core";
import {
checkUploadFile,
UPLOAD_FORMAT_RULES,
} from "../../src/commands/knowledge/upload-support.ts";
const fixtureDir = mkdtempSync(join(tmpdir(), "upload-support-"));
function writeFixture(fileName: string, content: string): string {
const filePath = join(fixtureDir, fileName);
writeFileSync(filePath, content);
return filePath;
}
describe("checkUploadFile", () => {
test("白名单内的小文件通过且无警告", () => {
const filePath = writeFixture("note.md", "# hello");
const result = checkUploadFile(filePath);
expect(result.sizeBytes).toBeGreaterThan(0);
expect(result.warning).toBeUndefined();
});
test("白名单外扩展名 (.zip) 抛 USAGE 并列出支持格式", () => {
const filePath = writeFixture("archive.zip", "PK");
try {
checkUploadFile(filePath);
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
expect((error as { hint?: string }).hint).toMatch(/\.pdf/);
}
});
test("文件不存在抛 GENERAL 且 hint 含 errno", () => {
try {
checkUploadFile(join(fixtureDir, "missing.md"));
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.GENERAL);
expect((error as { hint?: string }).hint).toMatch(/ENOENT/);
}
});
test("软限类型 (.md) 超 10MB 返回警告不拦截", () => {
const filePath = writeFixture("big.md", "x".repeat(11 * 1024 * 1024));
const result = checkUploadFile(filePath);
expect(result.warning).toMatch(/10 MB/);
});
});
describe("UPLOAD_FORMAT_RULES", () => {
test("硬限/软限分类符合设计表", () => {
expect(UPLOAD_FORMAT_RULES[".pdf"]).toEqual({ maxBytes: 150 * 1024 * 1024, enforce: "block" });
expect(UPLOAD_FORMAT_RULES[".png"]).toEqual({ maxBytes: 20 * 1024 * 1024, enforce: "block" });
expect(UPLOAD_FORMAT_RULES[".xlsx"]).toEqual({ maxBytes: 10 * 1024 * 1024, enforce: "warn" });
expect(UPLOAD_FORMAT_RULES[".csv"]).toBeDefined(); // inconsistent across public docs; kept in the allowlist
});
});
+49
View File
@@ -197,3 +197,52 @@ export function deploymentUpdatePath(deployedModel: string): string {
export function deploymentsModelsPath(): string {
return "/api/v1/deployments/models";
}
// ---- RAG admin plane (knowledge base / data center / service management, workspace-based host) ----
// All admin endpoints go through this factory; path constants are centralized in
// RAG_PATHS to avoid per-endpoint boilerplate.
export function ragEndpoint(workspaceId: string, path: string): string {
return `https://${workspaceId}.cn-beijing.maas.aliyuncs.com${path}`;
}
export const RAG_PATHS = {
// indices domain — knowledge bases / documents / chunks / import jobs.
// Note: parameter naming is inconsistent across endpoints; see per-path comments.
indexList: "/api/v1/indices/rag/index/list", // GET, pagination/filters go in the query string
indexCreateV2: "/api/v1/indices/rag/index/create_v2", // POST
indexUpdate: "/api/v1/indices/rag/index/update", // POST, index id parameter is named `id`
indexDelete: "/api/v1/indices/rag/index/delete", // POST, body { index_id }
indexMonitor: "/api/v1/indices/rag/index/monitor", // POST, second-precision string timestamps
indexFiles: "/api/v1/indices/rag/index/files", // GET, page parameter is page_num
indexDeleteFile: "/api/v1/indices/rag/index/delete_file", // POST, body { index_id, doc_ids }
indexJobCreate: "/api/v1/indices/rag/index/job/create", // POST, body requires nested dataSource { sourceType, fileIds } (flat documentIds from the docs is rejected)
indexJobStatus: "/api/v1/indices/rag/index_job/status", // GET, both index_id and job_id required
chunkList: "/api/v1/indices/rag/index/chunklist", // POST, body pageNum/pageSize
chunkCreate: "/api/v1/indices/rag/index/chunk/create", // POST, parameter is pipelineId; rate limit 10 req/s, no chunk_id in response
chunkUpdate: "/api/v1/indices/rag/index/chunk/update", // POST, parameter is pipelineId
chunkDelete: "/api/v1/indices/rag/index/chunk/delete", // POST, at most 10 per request
// agent domain — retrieval / chat services. The actual gateway prefix is rag/app/,
// not rag/agent/ as the public API docs state — the wrong path returns console HTML
// instead of an API response.
agentList: "/api/v1/indices/rag/app/list", // POST, agent_scene required
agentGet: "/api/v1/indices/rag/app/get", // POST
agentCreate: "/api/v1/indices/rag/app/create", // POST
agentUpdate: "/api/v1/indices/rag/app/update", // POST, config is only mutable on the beta draft
agentDeploy: "/api/v1/indices/rag/app/deploy", // POST
agentDelete: "/api/v1/indices/rag/app/delete", // POST, idempotent soft delete
agentCopy: "/api/v1/indices/rag/app/copy", // POST
// connector domain — data center (responses use requestId; cursor pagination via nextToken/maxResult)
applyFileUploadLease: "/api/v1/connector/dash/applyFileUploadLease", // sizeBytes must be a string
addFile: "/api/v1/connector/dash/addFile",
addFilesFromAuthorizedOss: "/api/v1/connector/dash/addFilesFromAuthorizedOss",
batchUpdateFileTag: "/api/v1/connector/dash/batchUpdateFileTag",
listFile: "/api/v1/connector/dash/listFile", // categoryId required
describeFile: "/api/v1/connector/dash/describeFile",
deleteFile: "/api/v1/connector/dash/deleteFile",
addConnector: "/api/v1/connector/dash/addConnector",
getConnector: "/api/v1/connector/dash/getConnector",
listCategory: "/api/v1/connector/dash/listCategory", // note: maxResult is singular
addCategory: "/api/v1/connector/dash/addCategory",
deleteCategory: "/api/v1/connector/dash/deleteCategory",
} as const;
+2
View File
@@ -14,6 +14,8 @@ export {
memorySearchPath,
mcpWebSearchPath,
profileSchemaPath,
ragEndpoint,
RAG_PATHS,
speechRecognizePath,
speechSynthesizePath,
taskPath,
+1
View File
@@ -3,6 +3,7 @@ export { mapApiError, type ApiErrorBody } from "./errors/api.ts";
export { ExitCode } from "./errors/codes.ts";
export type * from "./types/api.ts";
export type * from "./types/knowledge-admin.ts";
export * from "./auth/index.ts";
export * from "./client/index.ts";
export * from "./console/index.ts";
+4
View File
@@ -411,6 +411,8 @@ export interface DashScopeKnowledgeRetrieveResponse {
export interface KnowledgeSearchRequest {
query: string;
agent_id: string;
/** "beta" targets the debug draft; a numeric version targets that published version; defaults to the latest published version */
agent_version?: string;
images?: string[];
query_history?: Array<{ role: "user" | "assistant"; content: string }>;
}
@@ -461,6 +463,8 @@ export interface KnowledgeChatRequest {
parameters: {
agent_options: {
agent_id: string;
/** "beta" targets the debug draft; a numeric version targets that published version; defaults to the latest published version */
agent_version?: string;
user?: {
user_id?: string;
workspace_id?: string;
+1
View File
@@ -79,3 +79,4 @@ export type {
StreamChunk,
UserProfileResponse,
} from "./api.ts";
export type * from "./knowledge-admin.ts";
+349
View File
@@ -0,0 +1,349 @@
// Types for the RAG admin plane (indices / agent / connector domains). Modeled
// after the raw API field names — the mix of snake_case and camelCase reflects
// the server as-is and is intentionally NOT normalized here; normalization only
// happens at the CLI output layer. Kept separate from api.ts to avoid bloating it.
/** Common response envelope for the indices domain (top-level fields are snake_case) */
export interface RagResponse<T> {
code?: string;
message?: string;
status_code?: number;
request_id?: string;
data: T;
[key: string]: unknown;
}
/** GET index/list row (fields inside rows are camelCase as-is; full config field set) */
export interface RagIndexRow {
id: string;
name: string;
description?: string;
dataType?: string;
embeddingModelName?: string;
embeddingDimension?: number;
chunkSize?: number;
overlapSize?: number;
chunkMode?: string;
separator?: string;
rerankModelName?: string;
rerankMinScore?: number;
rerankTopN?: number;
rerankMode?: string;
enableRewrite?: boolean;
denseSimilarityTopK?: number;
sparseSimilarityTopK?: number;
sourceType?: string;
connectorId?: string;
[key: string]: unknown;
}
export interface RagIndexListData {
rows?: RagIndexRow[];
total?: number;
[key: string]: unknown;
}
export type RagIndexListResponse = RagResponse<RagIndexListData>;
/** GET index/files document row */
export interface RagIndexFileRow {
doc_id?: string;
doc_name?: string;
doc_type?: string;
status?: string;
size?: number | string;
ingestion_id?: string;
[key: string]: unknown;
}
export interface RagIndexFilesData {
rows?: RagIndexFileRow[];
total_count?: number;
[key: string]: unknown;
}
export type RagIndexFilesResponse = RagResponse<RagIndexFilesData>;
/**
* GET index_job/status (verified against the live API)
* Gotcha: the overall job state lives in `ingestion_status` (PENDING/RUNNING/COMPLETED,
* no FAILED value); the per-document list is `rows[]` (not docs[]), and failures
* surface via `rows[].code` (e.g. PARSE_FAILED).
*/
export interface RagIndexJobDoc {
doc_id?: string;
doc_name?: string;
doc_type?: string;
/** Fine-grained processing status code: FINISH / PARSE_FAILED / ... */
code?: string;
status?: string;
message?: string;
size?: number | string;
ingestion_id?: string;
[key: string]: unknown;
}
export interface RagIndexJobStatusData {
/** Overall job state: PENDING / RUNNING / COMPLETED */
ingestion_status?: string;
ingestion_message?: string;
rows?: RagIndexJobDoc[];
total_count?: number;
[key: string]: unknown;
}
export type RagIndexJobStatusResponse = RagResponse<RagIndexJobStatusData>;
/** Common response envelope for the connector domain (top-level requestId is camelCase — unlike the indices domain) */
export interface RagConnectorResponse<T> {
code?: string;
message?: string;
requestId?: string;
success?: boolean;
status?: number | string;
data: T;
[key: string]: unknown;
}
/** POST applyFileUploadLease */
export interface RagUploadLeaseParam {
url?: string;
method?: string;
headers?: Record<string, string>;
[key: string]: unknown;
}
export interface RagUploadLeaseData {
type?: string;
leaseId?: string;
param?: RagUploadLeaseParam;
[key: string]: unknown;
}
export type RagUploadLeaseResponse = RagConnectorResponse<RagUploadLeaseData>;
/** POST addFile */
export interface RagAddFileData {
fileId?: string;
parser?: string;
[key: string]: unknown;
}
export type RagAddFileResponse = RagConnectorResponse<RagAddFileData>;
/** POST listCategory */
export interface RagCategory {
categoryId?: string;
categoryName?: string;
isDefault?: boolean;
[key: string]: unknown;
}
export interface RagListCategoryData {
categoryList?: RagCategory[];
nextToken?: string;
[key: string]: unknown;
}
export type RagListCategoryResponse = RagConnectorResponse<RagListCategoryData>;
/** POST index/job/create (incremental import; response field names pending live verification) */
export interface RagJobCreateData {
ingestionId?: string;
[key: string]: unknown;
}
export type RagJobCreateResponse = RagResponse<RagJobCreateData>;
/** POST index/create_v2 */
export interface RagCreateIndexV2Data {
pipelineId?: string;
ingestionId?: string;
status?: string;
[key: string]: unknown;
}
export type RagCreateIndexV2Response = RagResponse<RagCreateIndexV2Data>;
/** POST index/update and index/delete — data carries no details (empty object or absent) */
export type RagMutationResponse = RagResponse<Record<string, unknown> | undefined>;
/** POST index/delete_file — data.deleted lists the document IDs actually deleted */
export interface RagDeleteFileData {
deleted?: string[];
[key: string]: unknown;
}
export type RagDeleteFileResponse = RagResponse<RagDeleteFileData>;
/** POST batchUpdateFileTag — connector-domain envelope, data is an empty object */
export type RagBatchUpdateTagResponse = RagConnectorResponse<Record<string, unknown> | undefined>;
// ---- agent domain (retrieval / chat service management) ----
// Parameters are snake_case; responses use the indices-domain envelope.
// Time fields are loosely modeled as string | number (live samples show
// millisecond timestamps; tighten once verified).
/** agent_config — chat and search scenes carry different field sets; modeled loosely as one type */
export interface RagAgentConfig {
agent_policy?: string;
agent_model?: string;
enable_session_file?: string;
enable_refusal?: string;
enable_anti_leak?: string;
enable_rich_text?: string;
enable_citation?: string;
temperature?: number;
max_num_llm_calls?: number;
max_completion_tokens?: number;
session_file_max_parse_length?: number;
enable_kb_router?: string;
kb_router_model?: string;
rerank_top_n?: number;
hybrid_rerank?: Record<string, unknown>;
kb_search_configs?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
/** agent/list row */
export interface RagAgentRow {
agent_id?: string;
agent_name?: string;
agent_scene?: string;
agent_status?: string;
agent_version?: string;
create_time?: string | number;
modify_time?: string | number;
pipeline_list?: Array<{ pipeline_id?: string; pipeline_name?: string }>;
[key: string]: unknown;
}
export interface RagAgentListData {
page_number?: number;
page_size?: number;
total_count?: number;
rows?: RagAgentRow[];
[key: string]: unknown;
}
export type RagAgentListResponse = RagResponse<RagAgentListData>;
/** agent/get per-version detail */
export interface RagAgentDetail {
agent_version?: string;
agent_version_desc?: string | null;
publish_time?: string | number;
agent_config?: RagAgentConfig;
[key: string]: unknown;
}
export interface RagAgentGetData {
agent_id?: string;
agent_name?: string;
agent_desc?: string;
agent_scene?: string;
agent_status?: string;
create_time?: string | number;
modify_time?: string | number;
agent_details?: RagAgentDetail[];
[key: string]: unknown;
}
export type RagAgentGetResponse = RagResponse<RagAgentGetData>;
/** agent/create · update · deploy · delete · copy — union of the data field sets */
export interface RagAgentMutationData {
agent_id?: string;
agent_name?: string;
agent_version?: string;
agent_status?: string;
[key: string]: unknown;
}
export type RagAgentMutationResponse = RagResponse<RagAgentMutationData>;
// ---- chunk / monitor / category / file / connector / oss-import ----
/** POST index/chunklist — nodes[].metadata carries the chunk payload */
export interface RagChunkNodeMetadata {
_id?: string;
doc_id?: string;
doc_name?: string;
title?: string;
content?: string;
hier_title?: string;
is_displayed_chunk_content?: boolean;
_chunk_status_message?: string;
[key: string]: unknown;
}
export interface RagChunkNode {
score?: number;
text?: string;
metadata?: RagChunkNodeMetadata;
[key: string]: unknown;
}
export interface RagChunkListData {
total?: number;
nodes?: RagChunkNode[];
[key: string]: unknown;
}
export type RagChunkListResponse = RagResponse<RagChunkListData>;
/** POST index/monitor timestamps are second-precision strings.
* Shape verified against the live API: both monitor fields are objects,
* not arrays as the public docs' empty-array examples suggest. */
export interface RagStorageMonitorData {
indexStorageLimit?: number;
indexStorageUsage?: number;
[key: string]: unknown;
}
export interface RagQpsMonitorData {
peakQps?: number;
monitorData?: Array<Record<string, unknown>>;
[key: string]: unknown;
}
export interface RagMonitorData {
pipelineCommercialType?: string;
storageMonitorData?: RagStorageMonitorData;
qpsMonitorData?: RagQpsMonitorData;
[key: string]: unknown;
}
export type RagMonitorResponse = RagResponse<RagMonitorData>;
/** POST addCategory */
export interface RagAddCategoryData {
categoryId?: string;
[key: string]: unknown;
}
export type RagAddCategoryResponse = RagConnectorResponse<RagAddCategoryData>;
/** POST listFile / describeFile field names verified against the live API:
* sizeBytes/uploadTime/category (not sizeInBytes/createTime/categoryId as the public docs state) */
export interface RagDataCenterFile {
fileId?: string;
fileName?: string;
fileType?: string;
parser?: string;
sizeBytes?: number | string;
md5?: string;
status?: string;
tags?: string[] | string;
category?: string;
uploadTime?: string;
parseErrorMessage?: string;
[key: string]: unknown;
}
export interface RagListFileData {
fileList?: RagDataCenterFile[];
nextToken?: string;
hasNext?: boolean;
[key: string]: unknown;
}
export type RagListFileResponse = RagConnectorResponse<RagListFileData>;
export type RagDescribeFileResponse = RagConnectorResponse<RagDataCenterFile>;
/** POST addConnector / getConnector */
export interface RagConnectorInfo {
connectorId?: string;
connectorName?: string;
description?: string;
connectorType?: string;
fileConnectorConfig?: {
storeType?: string;
ossRegionId?: string;
ossBucket?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type RagAddConnectorResponse = RagConnectorResponse<RagConnectorInfo>;
export type RagGetConnectorResponse = RagConnectorResponse<RagConnectorInfo>;
/** POST addFilesFromAuthorizedOss */
export interface RagOssImportData {
fileIds?: string[];
[key: string]: unknown;
}
export type RagOssImportResponse = RagConnectorResponse<RagOssImportData>;
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vite-plus/test";
import { ragEndpoint, RAG_PATHS } from "../src/client/endpoints.ts";
describe("ragEndpoint", () => {
test("拼接 workspace 子域名与路径", () => {
expect(ragEndpoint("ws_test", RAG_PATHS.indexList)).toBe(
"https://ws_test.cn-beijing.maas.aliyuncs.com/api/v1/indices/rag/index/list",
);
});
test("RAG_PATHS 覆盖全部管理端点域", () => {
// indices domain
expect(RAG_PATHS.indexCreateV2).toBe("/api/v1/indices/rag/index/create_v2");
expect(RAG_PATHS.indexJobStatus).toBe("/api/v1/indices/rag/index_job/status");
expect(RAG_PATHS.chunkList).toBe("/api/v1/indices/rag/index/chunklist");
expect(RAG_PATHS.chunkCreate).toBe("/api/v1/indices/rag/index/chunk/create");
// agent domain (the actual gateway prefix is rag/app/, not rag/agent/ as the public docs state)
expect(RAG_PATHS.agentList).toBe("/api/v1/indices/rag/app/list");
// connector domain
expect(RAG_PATHS.listCategory).toBe("/api/v1/connector/dash/listCategory");
});
});
+11
View File
@@ -63,3 +63,14 @@ export function isChatE2EReady(): boolean {
!!process.env.BAILIAN_E2E_CHAT_AGENT_ID?.trim() && !!process.env.BAILIAN_WORKSPACE_ID?.trim()
);
}
/** Knowledge admin commands (kb/doc/chunk/service/category/file) E2E readiness */
export function isKbAdminE2EReady(): boolean {
if (!isDashScopeE2EReady()) return false;
return !!process.env.BAILIAN_WORKSPACE_ID?.trim();
}
/** connector has no delete API, so live artifacts cannot be cleaned up — only enable explicitly for a full manual regression */
export function isConnectorE2EReady(): boolean {
return isKbAdminE2EReady() && process.env.BAILIAN_E2E_CONNECTOR === "1";
}
+1
View File
@@ -2,3 +2,4 @@ node_modules
dist
*.log
.DS_Store
docs
+62
View File
@@ -6,6 +6,37 @@ import {
knowledgeRetrieve,
knowledgeSearch,
knowledgeChat,
knowledgeKbList,
knowledgeKbInfo,
knowledgeDocList,
knowledgeDocStatus,
knowledgeDocUpload,
knowledgeKbCreate,
knowledgeKbUpdate,
knowledgeKbDelete,
knowledgeDocDelete,
knowledgeDocTag,
knowledgeServiceList,
knowledgeServiceGet,
knowledgeServiceCreate,
knowledgeServiceUpdate,
knowledgeServiceDeploy,
knowledgeServiceDelete,
knowledgeServiceCopy,
knowledgeChunkAdd,
knowledgeChunkList,
knowledgeChunkUpdate,
knowledgeChunkDelete,
knowledgeKbStats,
knowledgeCategoryList,
knowledgeCategoryAdd,
knowledgeCategoryDelete,
knowledgeFileList,
knowledgeFileGet,
knowledgeFileDelete,
knowledgeCollectionCreate,
knowledgeCollectionGet,
knowledgeDocImportOss,
} from "bailian-cli-commands";
// kscli (Knowledge Studio CLI): lightweight RAG product. Ships config/update
@@ -20,4 +51,35 @@ export const commands: Record<string, AnyCommand> = {
retrieve: knowledgeRetrieve,
search: knowledgeSearch,
chat: knowledgeChat,
"kb list": knowledgeKbList,
"kb info": knowledgeKbInfo,
"kb create": knowledgeKbCreate,
"kb update": knowledgeKbUpdate,
"kb delete": knowledgeKbDelete,
"doc list": knowledgeDocList,
"doc status": knowledgeDocStatus,
"doc upload": knowledgeDocUpload,
"doc delete": knowledgeDocDelete,
"doc tag": knowledgeDocTag,
"service list": knowledgeServiceList,
"service get": knowledgeServiceGet,
"service create": knowledgeServiceCreate,
"service update": knowledgeServiceUpdate,
"service deploy": knowledgeServiceDeploy,
"service delete": knowledgeServiceDelete,
"service copy": knowledgeServiceCopy,
"chunk add": knowledgeChunkAdd,
"chunk list": knowledgeChunkList,
"chunk update": knowledgeChunkUpdate,
"chunk delete": knowledgeChunkDelete,
"kb stats": knowledgeKbStats,
"category list": knowledgeCategoryList,
"category add": knowledgeCategoryAdd,
"category delete": knowledgeCategoryDelete,
"file list": knowledgeFileList,
"file get": knowledgeFileGet,
"file delete": knowledgeFileDelete,
"collection create": knowledgeCollectionCreate,
"collection get": knowledgeCollectionGet,
"doc import-oss": knowledgeDocImportOss,
};
+36
View File
@@ -0,0 +1,36 @@
// Confirmation guard for dangerous operations — used by irreversible or
// production-affecting commands (kb/doc/chunk/category/file delete, service
// delete/deploy, ...).
import { createInterface } from "node:readline/promises";
import { BailianError, ExitCode } from "bailian-cli-core";
/**
* - `yes` (the command's own --yes switch) pass through
* - TTY: print the summary and wait for y/yes (case-insensitive); any other
* input cancels with exit SUCCESS (cancellation is not an error)
* - non-TTY without --yes: throw USAGE
*/
export async function confirmDangerousAction(summary: string, yes: boolean): Promise<void> {
if (yes) return;
if (!process.stdin.isTTY) {
throw new BailianError(
"Confirmation required for this destructive action.",
ExitCode.USAGE,
"Re-run with --yes to confirm in non-interactive mode",
);
}
process.stderr.write(`${summary}\n`);
const readline = createInterface({ input: process.stdin, output: process.stderr });
try {
const answer = (await readline.question("Proceed? [y/N] ")).trim().toLowerCase();
if (answer !== "y" && answer !== "yes") {
process.stderr.write("Cancelled.\n");
// Intentional: a user-initiated cancellation is not an error, and we want
// to exit here rather than unwind through the middleware stack (which
// would still print a success report for an action that did not happen).
process.exit(ExitCode.SUCCESS);
}
} finally {
readline.close();
}
}
+1
View File
@@ -57,6 +57,7 @@ export {
// Utility facilities consumed by commands
export { poll } from "./utils/polling.ts";
export { confirmDangerousAction } from "./confirm.ts";
export { downloadFile, formatBytes } from "./utils/download.ts";
export { runConcurrent, getConcurrency, downloadParallel } from "./utils/concurrent.ts";
export { resolveImageSize } from "./utils/image-size.ts";
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, test } from "vite-plus/test";
import { ExitCode } from "bailian-cli-core";
import { confirmDangerousAction } from "../src/confirm.ts";
const originalIsTTY = process.stdin.isTTY;
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});
describe("confirmDangerousAction", () => {
test("--yes 时直接通过,不触碰 stdin", async () => {
await expect(confirmDangerousAction("Delete kb idx-1", true)).resolves.toBeUndefined();
});
test("非 TTY 且无 --yes 时抛 USAGE 并引导 --yes", async () => {
process.stdin.isTTY = false;
try {
await confirmDangerousAction("Delete kb idx-1", false);
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
expect((error as { hint?: string }).hint).toMatch(/--yes/);
}
});
});
-4
View File
@@ -46,10 +46,6 @@ catalogs:
specifier: ^3.4.0
version: 3.4.0
overrides:
vite: npm:@voidzero-dev/vite-plus-core@latest
vitest: npm:@voidzero-dev/vite-plus-test@latest
importers:
.:
+111 -80
View File
@@ -9,89 +9,120 @@ Use this index for the skill-scoped quick index and global flags.
## Quick index
| Command | Description | Detail |
| ------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) |
| `bl app list` | List Bailian applications | [app.md](app.md) |
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
| `bl auth status` | Show current authentication state | [auth.md](auth.md) |
| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) |
| `bl config list` | List config profiles and show the active profile | [config.md](config.md) |
| `bl config set` | Set a config value | [config.md](config.md) |
| `bl config show` | Display current configuration | [config.md](config.md) |
| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) |
| `bl config use` | Set the active config profile | [config.md](config.md) |
| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) |
| `bl memory delete` | Delete a memory node | [memory.md](memory.md) |
| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) |
| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) |
| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) |
| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) |
| `bl memory update` | Update a memory node content | [memory.md](memory.md) |
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
| `bl quota history` | View quota change history | [quota.md](quota.md) |
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
| Command | Description | Detail |
| -------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ |
| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) |
| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) |
| `bl app list` | List Bailian applications | [app.md](app.md) |
| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) |
| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) |
| `bl auth logout` | Clear stored credentials; full logout also clears the model Base URL | [auth.md](auth.md) |
| `bl auth status` | Show current authentication state | [auth.md](auth.md) |
| `bl config agent` | Configure a coding agent to use DashScope API | [config.md](config.md) |
| `bl config list` | List config profiles and show the active profile | [config.md](config.md) |
| `bl config set` | Set a config value | [config.md](config.md) |
| `bl config show` | Display current configuration | [config.md](config.md) |
| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) |
| `bl config use` | Set the active config profile | [config.md](config.md) |
| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) |
| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) |
| `bl knowledge category add` | Create a data-center category | [knowledge.md](knowledge.md) |
| `bl knowledge category delete` | Delete a data-center category | [knowledge.md](knowledge.md) |
| `bl knowledge category list` | List data-center categories | [knowledge.md](knowledge.md) |
| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) |
| `bl knowledge chunk add` | Add a chunk directly to a knowledge base | [knowledge.md](knowledge.md) |
| `bl knowledge chunk delete` | Delete chunks from a knowledge base (irreversible) | [knowledge.md](knowledge.md) |
| `bl knowledge chunk list` | List chunks in a knowledge base with content and status | [knowledge.md](knowledge.md) |
| `bl knowledge chunk update` | Update chunk content or toggle its retrieval visibility | [knowledge.md](knowledge.md) |
| `bl knowledge collection create` | Create a FILE data collection | [knowledge.md](knowledge.md) |
| `bl knowledge collection get` | Show data collection details | [knowledge.md](knowledge.md) |
| `bl knowledge create` | Create a knowledge base and import data-center files or categories | [knowledge.md](knowledge.md) |
| `bl knowledge delete` | Delete a knowledge base with all its documents and chunks | [knowledge.md](knowledge.md) |
| `bl knowledge doc delete` | Delete documents and their chunks from a knowledge base | [knowledge.md](knowledge.md) |
| `bl knowledge doc import-oss` | Batch import files from an authorized OSS bucket into the data center | [knowledge.md](knowledge.md) |
| `bl knowledge doc list` | List documents in a knowledge base with parse/index status | [knowledge.md](knowledge.md) |
| `bl knowledge doc status` | Check knowledge base import job status | [knowledge.md](knowledge.md) |
| `bl knowledge doc tag` | Batch update tags on data-center files | [knowledge.md](knowledge.md) |
| `bl knowledge doc upload` | Upload local files to the data center and optionally import into a knowledge base | [knowledge.md](knowledge.md) |
| `bl knowledge file delete` | Permanently delete a file from the data center | [knowledge.md](knowledge.md) |
| `bl knowledge file get` | Show data-center file details (size, MD5, tags, timestamps) | [knowledge.md](knowledge.md) |
| `bl knowledge file list` | List files in a data-center category | [knowledge.md](knowledge.md) |
| `bl knowledge info` | Show knowledge base configuration details | [knowledge.md](knowledge.md) |
| `bl knowledge list` | List knowledge bases in the workspace | [knowledge.md](knowledge.md) |
| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) |
| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) |
| `bl knowledge service copy` | Copy a service into a new draft (name gets a copy\_ prefix) | [knowledge.md](knowledge.md) |
| `bl knowledge service create` | Create a retrieval / Q&A service (initial status: draft, version: beta) | [knowledge.md](knowledge.md) |
| `bl knowledge service delete` | Delete a retrieval / Q&A service (soft delete, idempotent) | [knowledge.md](knowledge.md) |
| `bl knowledge service deploy` | Publish the beta draft of a service as a new version | [knowledge.md](knowledge.md) |
| `bl knowledge service get` | Show service (agent) details including per-version configuration | [knowledge.md](knowledge.md) |
| `bl knowledge service list` | List retrieval / Q&A services (agents) in the workspace | [knowledge.md](knowledge.md) |
| `bl knowledge service update` | Update service name, description or draft configuration | [knowledge.md](knowledge.md) |
| `bl knowledge stats` | Show knowledge base storage and QPS monitoring data | [knowledge.md](knowledge.md) |
| `bl knowledge update` | Update knowledge base name, description or rerank threshold | [knowledge.md](knowledge.md) |
| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) |
| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) |
| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) |
| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) |
| `bl memory delete` | Delete a memory node | [memory.md](memory.md) |
| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) |
| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) |
| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) |
| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) |
| `bl memory update` | Update a memory node content | [memory.md](memory.md) |
| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) |
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
| `bl quota history` | View quota change history | [quota.md](quota.md) |
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) |
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) |
| `bl workspace init` | Initialize Bailian workspace and activate postpaid services | [workspace.md](workspace.md) |
| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) |
## By group
| Group | Commands | Reference |
| ------------ | ---------------------------------------------------------------------------- | ------------------------------ |
| `advisor` | `recommend` | [advisor.md](advisor.md) |
| `app` | `call`, `list` | [app.md](app.md) |
| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) |
| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) |
| `console` | `call` | [console.md](console.md) |
| `file` | `upload` | [file.md](file.md) |
| `knowledge` | `chat`, `retrieve`, `search` | [knowledge.md](knowledge.md) |
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
| `model` | `list` | [model.md](model.md) |
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
| Group | Commands | Reference |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| `advisor` | `recommend` | [advisor.md](advisor.md) |
| `app` | `call`, `list` | [app.md](app.md) |
| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) |
| `config` | `agent`, `list`, `set`, `show`, `ui`, `use` | [config.md](config.md) |
| `console` | `call` | [console.md](console.md) |
| `file` | `upload` | [file.md](file.md) |
| `knowledge` | `category add`, `category delete`, `category list`, `chat`, `chunk add`, `chunk delete`, `chunk list`, `chunk update`, `collection create`, `collection get`, `create`, `delete`, `doc delete`, `doc import-oss`, `doc list`, `doc status`, `doc tag`, `doc upload`, `file delete`, `file get`, `file list`, `info`, `list`, `retrieve`, `search`, `service copy`, `service create`, `service delete`, `service deploy`, `service get`, `service list`, `service update`, `stats`, `update` | [knowledge.md](knowledge.md) |
| `mcp` | `call`, `list`, `tools` | [mcp.md](mcp.md) |
| `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.md) |
| `model` | `list` | [model.md](model.md) |
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
| `text` | `chat` | [text.md](text.md) |
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
| `update` | `(root)` | [update.md](update.md) |
| `usage` | `free`, `freetier`, `stats`, `summary` | [usage.md](usage.md) |
| `workspace` | `init`, `list` | [workspace.md](workspace.md) |
## Global flags
File diff suppressed because it is too large Load Diff