refactor(knowledge): migrate high-risk commands to runtime confirmation

This commit is contained in:
若麒
2026-08-27 19:42:51 +08:00
parent 00bcee36a6
commit 8906108744
29 changed files with 250 additions and 346 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ bl knowledge chunk delete --index-id <id> --chunk-id <id> [flags]
| ----------------- | ------ | ---- | ------------------------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--chunk-id <id>` | array | 是 | Chunk ID可重复每批最多 10 个,超出自动分批) |
| `--yes` | switch | 否 | 跳过确认提示 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+4 -4
View File
@@ -232,10 +232,10 @@ bl knowledge category delete --category-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------ |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ---------------------- |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+4 -4
View File
@@ -120,10 +120,10 @@ bl knowledge file delete --file-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | --------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | ---------------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+5 -5
View File
@@ -254,10 +254,10 @@ bl knowledge delete --index-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。
- **不可逆操作**:知识库及所有索引内容被永久删除。
- 数据中心中的源文件不受影响,仅删除知识库索引。
- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要
- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求
**示例**
+1 -1
View File
@@ -135,7 +135,7 @@
### 危险操作确认
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete``service deploy`在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete``service deploy`属于高风险操作。未带 `--yes` 时 CLI 不会执行,也不会弹出交互式 Y/N而是返回 exit code 7 和 `requires_confirmation`;确认后在原命令中添加 `--yes` 重新执行
### Dry-run 模式
+9 -9
View File
@@ -270,11 +270,11 @@ bl knowledge service deploy --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -319,10 +319,10 @@ bl knowledge service delete --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+1 -1
View File
@@ -213,7 +213,7 @@ kscli chunk delete --index-id <id> --chunk-id <id> [flags]
| ----------------- | ------ | ---- | ------------------------------------------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--chunk-id <id>` | array | 是 | Chunk ID可重复每批最多 10 个,超出自动分批) |
| `--yes` | switch | 否 | 跳过确认提示 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+4 -4
View File
@@ -232,10 +232,10 @@ kscli category delete --category-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------ |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ---------------------- |
| `--category-id <id>` | string | 是 | 分类 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+4 -4
View File
@@ -120,10 +120,10 @@ kscli file delete --file-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | --------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ---------------- | ------ | ---- | ---------------------- |
| `--file-id <id>` | string | 是 | 数据中心文件 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+5 -5
View File
@@ -254,10 +254,10 @@ kscli kb delete --index-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ------------ |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--index-id <id>` | string | 是 | 知识库 ID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。
- **不可逆操作**:知识库及所有索引内容被永久删除。
- 数据中心中的源文件不受影响,仅删除知识库索引。
- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要
- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求
**示例**
+1 -1
View File
@@ -152,7 +152,7 @@ kscli --help
### 危险操作确认
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete`)以及 `service deploy` 在执行前会弹出二次确认提示。使用 `--yes` 可跳过确认,适用于自动化脚本
涉及删除的命令(`kb delete``doc delete``chunk delete``file delete``category delete``service delete`)以及 `service deploy` 属于高风险操作。未带 `--yes` 时 CLI 不会执行,也不会弹出交互式 Y/N而是返回 exit code 7 和 `requires_confirmation`;确认后在原命令中添加 `--yes` 重新执行
### Dry-run 模式
+9 -9
View File
@@ -270,11 +270,11 @@ kscli service deploy --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--version-desc <text>` | string | 否 | 新版本的描述说明 |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
@@ -319,10 +319,10 @@ kscli service delete --agent-id <id> [flags]
**参数**
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | --------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 跳过确认提示 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------ | ---- | ---------------------- |
| `--agent-id <id>` | string | 是 | 服务agentID |
| `--yes` | switch | 否 | 显式确认执行高风险操作 |
**输出**
+2 -2
View File
@@ -116,9 +116,9 @@
**Flags**`--index-id` 必填;`--doc-id` array 必填(可重复);`--yes`
**实现方案**`doc-delete.ts`确认摘要含 index_id + doc_id 列表≤5 个全列,超出显示前 5 + 总数);输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
**实现方案**`doc-delete.ts`命令在 `risk` 对象中同时声明 `level: "high"` 和双语 `message`,由 runtime 在 `run()` 前统一确认;输出以 `data.deleted` 为准(与入参数量不一致时 text 模式警告差异)。
**测试方案**help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 非 TTY`--yes` exitCode 2 / live 配合 upload 清理链。
**测试方案**help / 缺参×2 / dry-run 断言 `doc_ids` 数组 / 无 `--yes` 返回 exitCode 7 + `requires_confirmation` / live 配合 upload 清理链。
## doc tag — 批量更新文档标签
@@ -6,7 +6,7 @@ import {
type FlagsDef,
type RagConnectorResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CATEGORY_DELETE_FLAGS = {
@@ -16,16 +16,19 @@ const CATEGORY_DELETE_FLAGS = {
description: { "en-US": "Category ID to delete", "zh-CN": "要删除的类目 ID" },
required: true,
},
yes: {
type: "switch",
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
export default defineCommand({
description: { "en-US": "Delete a data-center category", "zh-CN": "删除数据中心类目" },
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This deletes the selected data-center category and cannot be undone.",
"zh-CN": "该操作会删除所选数据中心类目,且无法撤销。",
},
},
usageArgs: "--category-id <id> [flags]",
flags: CATEGORY_DELETE_FLAGS,
notes: [
@@ -49,11 +52,6 @@ export default defineCommand({
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>
>({
@@ -7,7 +7,7 @@ import {
type FlagsDef,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const CHUNK_DELETE_FLAGS = {
@@ -26,10 +26,6 @@ const CHUNK_DELETE_FLAGS = {
},
required: true,
},
yes: {
type: "switch",
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
},
...WORKSPACE_FLAG,
} satisfies FlagsDef;
@@ -48,6 +44,13 @@ export default defineCommand({
"zh-CN": "从知识库中删除 Chunk不可撤销",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US": "This permanently deletes the selected chunks and cannot be undone.",
"zh-CN": "该操作会永久删除所选 Chunk且无法撤销。",
},
},
usageArgs: "--index-id <id> --chunk-id <id> [flags]",
flags: CHUNK_DELETE_FLAGS,
notes: [
@@ -81,11 +84,6 @@ export default defineCommand({
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) {
@@ -3,12 +3,10 @@ import {
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 { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const FILE_DELETE_FLAGS = {
@@ -21,39 +19,23 @@ const FILE_DELETE_FLAGS = {
},
required: true,
},
yes: {
type: "switch",
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
},
...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: {
"en-US": "Permanently delete a file from the data center",
"zh-CN": "从数据中心永久删除文件",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This permanently deletes the data-center file. Knowledge-base document indexes that reference it may become invalid.",
"zh-CN": "该操作会永久删除数据中心文件;引用该文件的知识库文档索引可能失效。",
},
},
usageArgs: "--file-id <id> [flags]",
flags: FILE_DELETE_FLAGS,
notes: [
@@ -82,11 +64,6 @@ export default defineCommand({
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>
>({
@@ -4,12 +4,10 @@ import {
RAG_PATHS,
detectOutputFormat,
type FlagsDef,
type RagIndexFilesResponse,
type RagMutationResponse,
} from "bailian-cli-core";
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
import { emitResult, emitBare } from "bailian-cli-runtime";
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
import { fetchIndexDetail } from "./kb-info.ts";
const KB_DELETE_FLAGS = {
indexId: {
@@ -18,50 +16,23 @@ const KB_DELETE_FLAGS = {
description: { "en-US": "Knowledge base ID", "zh-CN": "知识库 ID" },
required: true,
},
yes: {
type: "switch",
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
},
...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: {
"en-US": "Delete a knowledge base with all its documents and chunks",
"zh-CN": "删除知识库及其所有文档和 Chunk",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This permanently deletes the knowledge base and all of its documents and chunks. Data-center files are not deleted.",
"zh-CN": "该操作会永久删除知识库及其全部文档和 Chunk但不会删除数据中心文件。",
},
},
usageArgs: "--index-id <id> [flags]",
flags: KB_DELETE_FLAGS,
notes: [
@@ -90,11 +61,6 @@ export default defineCommand({
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",
@@ -3,12 +3,10 @@ import {
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 { emitResult, emitBare } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_DELETE_FLAGS = {
@@ -18,48 +16,23 @@ const SERVICE_DELETE_FLAGS = {
description: { "en-US": "Service (agent) ID", "zh-CN": "服务AgentID" },
required: true,
},
yes: {
type: "switch",
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
},
...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: {
"en-US": "Delete a retrieval / Q&A service (soft delete, idempotent)",
"zh-CN": "删除检索/问答服务(软删除,幂等)",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This deletes the service and makes its agent ID unavailable for search and chat calls. The operation cannot be undone.",
"zh-CN": "该操作会删除服务,使其 Agent ID 无法再用于搜索和对话调用,且无法撤销。",
},
},
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_DELETE_FLAGS,
notes: [
@@ -91,11 +64,6 @@ export default defineCommand({
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",
@@ -3,12 +3,10 @@ import {
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 { emitResult, emitBare } from "bailian-cli-runtime";
import { agentMutationField, resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
const SERVICE_DEPLOY_FLAGS = {
@@ -26,49 +24,23 @@ const SERVICE_DEPLOY_FLAGS = {
"zh-CN": "新发布版本的描述",
},
},
yes: {
type: "switch",
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
},
...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: {
"en-US": "Publish the beta draft of a service as a new version",
"zh-CN": "将服务的 beta 草稿发布为新版本",
},
auth: "apiKey",
risk: {
level: "high",
message: {
"en-US":
"This publishes the current draft as a new version and changes the behavior seen by live callers.",
"zh-CN": "该操作会将当前草稿发布为新版本,并改变线上调用方使用的服务行为。",
},
},
usageArgs: "--agent-id <id> [flags]",
flags: SERVICE_DEPLOY_FLAGS,
notes: [
@@ -109,11 +81,6 @@ export default defineCommand({
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",
@@ -433,7 +433,7 @@ describe("e2e: knowledge chunk 组 (静态)", () => {
expect(data.batches[1]!.request.chunkIds).toHaveLength(2);
});
test("chunk delete: 非 TTY 无 --yes 报 USAGE (2)", async () => {
test("chunk delete: 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"chunk",
@@ -446,9 +446,13 @@ describe("e2e: knowledge chunk 组 (静态)", () => {
"sk-fake",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
});
@@ -549,7 +553,22 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", ()
expect(data.request?.connectorId).toBe("conn_test");
});
test("category delete: 非 TTY 无 --yes 报 USAGE (2)", async () => {
test("category delete: dry-run 断言 categoryId", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"category",
"delete",
"--category-id",
"cate_test",
...COMMON,
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/deleteCategory/);
expect(data.request?.categoryId).toBe("cate_test");
});
test("category delete: 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"category",
@@ -560,9 +579,13 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", ()
"sk-fake",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("file list: 缺 --category-id 报 USAGE (2)", async () => {
@@ -618,7 +641,22 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", ()
expect(data.request?.fileId).toBe("file_test");
});
test("file delete: 非 TTY 无 --yes 报 USAGE (2)", async () => {
test("file delete: dry-run 断言 fileId", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"file",
"delete",
"--file-id",
"file_test",
...COMMON,
]);
expect(exitCode, stderr).toBe(0);
const data = parseStdoutJson<DryRunBody>(stdout);
expect(data.endpoint).toMatch(/deleteFile/);
expect(data.request?.fileId).toBe("file_test");
});
test("file delete: 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"file",
@@ -629,9 +667,13 @@ describe("e2e: kb stats / category / file / connector / import-oss (静态)", ()
"sk-fake",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("collection create: --name 21 字符 USAGE (2)", async () => {
@@ -48,7 +48,7 @@ describe("e2e: knowledge kb delete", () => {
expect(data.request?.index_id).toBe("idx_test");
});
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
test("无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"delete",
@@ -58,9 +58,17 @@ describe("e2e: knowledge kb delete", () => {
"sk-fake",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: {
code: 7,
type: "requires_confirmation",
hint: expect.stringContaining("--yes"),
},
});
});
});
@@ -454,7 +454,7 @@ describe("e2e: knowledge service update", () => {
});
describe("e2e: knowledge service deploy / delete (危险)", () => {
test("deploy: 非 TTY 无 --yes 报 USAGE (2)", async () => {
test("deploy: 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
@@ -465,9 +465,13 @@ describe("e2e: knowledge service deploy / delete (危险)", () => {
"sk-fake",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("deploy: --dry-run 断言 body", async () => {
@@ -491,7 +495,7 @@ describe("e2e: knowledge service deploy / delete (危险)", () => {
expect(data.request?.agent_version_desc).toBe("v1 desc");
});
test("delete: 非 TTY 无 --yes 报 USAGE (2)", async () => {
test("delete: 无 --yes 返回确认请求 (7)", async () => {
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
@@ -502,9 +506,32 @@ describe("e2e: knowledge service deploy / delete (危险)", () => {
"sk-fake",
"--workspace-id",
"ws_test",
"--output",
"json",
]);
expect(exitCode).toBe(2);
expect(stderr).toMatch(/--yes/);
expect(exitCode).toBe(7);
expect(JSON.parse(stderr)).toMatchObject({
error: { code: 7, type: "requires_confirmation" },
});
});
test("delete: --dry-run 断言 body", async () => {
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"delete",
"--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\/delete/);
expect(data.request?.agent_id).toBe("aid_test");
});
});
@@ -79,6 +79,13 @@ function isLocalizedText(value: unknown): value is LocalizedText {
);
}
function isCommandRisk(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const risk = value as Record<string, unknown>;
return risk.level === "high" && isLocalizedText(risk.message);
}
function assertCommand(path: string, value: unknown): asserts value is CommandPackCommand<any> {
if (!value || typeof value !== "object") {
throw new Error(`Command "${path}" must export an object.`);
@@ -90,8 +97,8 @@ function assertCommand(path: string, value: unknown): asserts value is CommandPa
if (!command.auth || !AUTH_REQUIREMENTS.has(command.auth)) {
throw new Error(`Command "${path}" has an invalid auth requirement.`);
}
if (command.risk !== undefined && !isLocalizedText(command.risk.message)) {
throw new Error(`Command "${path}" has an invalid risk message.`);
if (command.risk !== undefined && !isCommandRisk(command.risk)) {
throw new Error(`Command "${path}" has invalid risk metadata.`);
}
if (typeof command.run !== "function") {
throw new Error(`Command "${path}" is missing run(ctx).`);
-34
View File
@@ -1,4 +1,3 @@
import { createInterface } from "node:readline/promises";
import {
BailianError,
ExitCode,
@@ -22,39 +21,6 @@ export function confirmationFlagDefs(command: { risk?: CommandRisk }): FlagsDef
return command.risk === undefined ? {} : CONFIRMATION_FLAGS;
}
/**
* Transitional compatibility for commands that have not moved to command-level
* risk metadata yet.
* TODO(next commit): migrate every remaining caller to command-level risk metadata, then
* remove this helper and update their confirmation wording/examples together.
*
* @deprecated Declare command-level risk metadata and let runtime gate confirmation.
*/
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();
}
}
export function confirmationHint(): LocalizedText {
return {
"en-US":
-1
View File
@@ -63,7 +63,6 @@ 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";
@@ -163,6 +163,42 @@ test("loads an API 1 Command Pack and preserves its command contract", async ()
expect(commands["agent ping"]?.flags?.message).toMatchObject({ required: true, type: "string" });
});
test.each([
["null", null],
["a non-object value", "high"],
["a missing level", { message: "Dangerous operation." }],
["an unsupported level", { level: "low", message: "Dangerous operation." }],
["an invalid message", { level: "high", message: "" }],
])("rejects Command Pack risk metadata with %s", async (_caseName, risk) => {
const root = await mkdtemp(join(tmpdir(), "command-pack-risk-test-"));
try {
await writeFile(
join(root, "commands.mjs"),
`export default {
"agent dangerous": {
description: "Dangerous command",
auth: "none",
risk: ${JSON.stringify(risk)},
async run() {},
},
};\n`,
);
await expect(
loadAndValidateCommandPack(
"@ali/bailian-plugin-agent",
packageJson,
policy.supported["@ali/bailian-plugin-agent"]!,
identity,
root,
),
).rejects.toThrow(/invalid risk/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("rejects incompatible protocol versions and invalid command prefixes", async () => {
await expect(
loadAndValidateCommandPack(
+2 -46
View File
@@ -1,32 +1,8 @@
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import { describe, expect, test, vi } from "vite-plus/test";
import { defineCommand, ExitCode, type CommandRisk, type LocalizedText } from "bailian-cli-core";
import {
ConfirmationRequiredError,
confirmDangerousAction,
confirmationFlagDefs,
} from "../src/confirm.ts";
import { ConfirmationRequiredError, confirmationFlagDefs } from "../src/confirm.ts";
import { confirmationStage, type RunContext } from "../src/middleware.ts";
const readlineMocks = vi.hoisted(() => {
const question = vi.fn(async () => "y");
const close = vi.fn();
return {
question,
close,
createInterface: vi.fn(() => ({ question, close })),
};
});
vi.mock("node:readline/promises", () => ({ createInterface: readlineMocks.createInterface }));
const originalIsTTY = process.stdin.isTTY;
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
vi.clearAllMocks();
});
const HIGH_RISK_MESSAGE = {
"en-US": "This permanently deletes the document and its chunks.",
"zh-CN": "该操作会永久删除文档及其 Chunk且无法撤销。",
@@ -83,26 +59,6 @@ describe("confirmation metadata", () => {
},
});
});
test("legacy commands fail closed without opening a TTY prompt", async () => {
process.stdin.isTTY = false;
await expect(confirmDangerousAction("legacy summary", false)).rejects.toMatchObject({
exitCode: ExitCode.USAGE,
});
await expect(confirmDangerousAction("legacy summary", true)).resolves.toBeUndefined();
});
test("legacy commands retain their existing TTY confirmation during migration", async () => {
process.stdin.isTTY = true;
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
await expect(confirmDangerousAction("legacy summary", false)).resolves.toBeUndefined();
expect(stderrWrite).toHaveBeenCalledWith("legacy summary\n");
expect(readlineMocks.createInterface).toHaveBeenCalledOnce();
expect(readlineMocks.question).toHaveBeenCalledWith("Proceed? [y/N] ");
expect(readlineMocks.close).toHaveBeenCalledOnce();
});
});
describe("confirmationStage", () => {
-11
View File
@@ -1,11 +0,0 @@
import { expect, test } from "vite-plus/test";
import * as runtimeApi from "../src/index.ts";
test("confirmation orchestration stays internal to createCli", () => {
expect(runtimeApi).toHaveProperty("confirmDangerousAction");
expect(runtimeApi).not.toHaveProperty("confirmationStage");
expect(runtimeApi).not.toHaveProperty("CONFIRMATION_FLAGS");
expect(runtimeApi).not.toHaveProperty("confirmationFlagDefs");
expect(runtimeApi).not.toHaveProperty("ConfirmationRequiredError");
expect(runtimeApi.CommandRegistry.prototype).not.toHaveProperty("localizeText");
});
+6 -6
View File
@@ -94,8 +94,8 @@ bl knowledge category add --name sub --parent-id cate-xxx
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | --------------------------------------------------------------- |
| `--category-id <id>` | string | yes | Category ID to delete |
| `--yes` | switch | no | Skip the confirmation prompt |
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
@@ -255,8 +255,8 @@ bl knowledge chunk add --index-id idx-xxx --field columnA=v1 --field columnB=v2
| --------------------- | ------ | -------- | --------------------------------------------------------------------- |
| `--index-id <id>` | string | yes | Knowledge base ID |
| `--chunk-id <id>` | array | yes | Chunk ID to delete (repeatable; batches of 10 are sent automatically) |
| `--yes` | switch | no | Skip the confirmation prompt |
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
@@ -473,8 +473,8 @@ bl knowledge create --name demo --description 'product docs' --category-id cate-
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | --------------------------------------------------------------- |
| `--index-id <id>` | string | yes | Knowledge base ID |
| `--yes` | switch | no | Skip the confirmation prompt |
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
@@ -740,8 +740,8 @@ bl knowledge doc upload --file ./docs/ --dry-run --verbose
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | --------------------------------------------------------------- |
| `--file-id <id>` | string | yes | Data-center file ID to delete |
| `--yes` | switch | no | Skip the confirmation prompt |
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
@@ -1044,8 +1044,8 @@ bl knowledge service create --name my-search --scene search --index-id idx-xxx
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | --------------------------------------------------------------- |
| `--agent-id <id>` | string | yes | Service (agent) ID |
| `--yes` | switch | no | Skip the confirmation prompt |
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
@@ -1080,8 +1080,8 @@ bl knowledge service delete --agent-id aid-xxx --yes
| ----------------------- | ------ | -------- | --------------------------------------------------------------- |
| `--agent-id <id>` | string | yes | Service (agent) ID |
| `--version-desc <text>` | string | no | Description for the newly published version |
| `--yes` | switch | no | Skip the confirmation prompt |
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
| `--yes` | switch | no | Confirm this high-risk operation |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |