diff --git a/docs/knowledge/chunk.md b/docs/knowledge/chunk.md index 378ee80..90926ee 100644 --- a/docs/knowledge/chunk.md +++ b/docs/knowledge/chunk.md @@ -213,7 +213,7 @@ bl knowledge chunk delete --index-id --chunk-id [flags] | ----------------- | ------ | ---- | ------------------------------------------------ | | `--index-id ` | string | 是 | 知识库 ID | | `--chunk-id ` | array | 是 | Chunk ID(可重复,每批最多 10 个,超出自动分批) | -| `--yes` | switch | 否 | 跳过确认提示 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/knowledge/collection-category.md b/docs/knowledge/collection-category.md index 92a24aa..e2e03a1 100644 --- a/docs/knowledge/collection-category.md +++ b/docs/knowledge/collection-category.md @@ -232,10 +232,10 @@ bl knowledge category delete --category-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| -------------------- | ------ | ---- | ------------ | -| `--category-id ` | string | 是 | 分类 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------ | ---- | ---------------------- | +| `--category-id ` | string | 是 | 分类 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/knowledge/file.md b/docs/knowledge/file.md index 4f88a19..ce5b02b 100644 --- a/docs/knowledge/file.md +++ b/docs/knowledge/file.md @@ -120,10 +120,10 @@ bl knowledge file delete --file-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ---------------- | ------ | ---- | --------------- | -| `--file-id ` | string | 是 | 数据中心文件 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ---------------- | ------ | ---- | ---------------------- | +| `--file-id ` | string | 是 | 数据中心文件 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/knowledge/kb.md b/docs/knowledge/kb.md index 83fc10e..a1be438 100644 --- a/docs/knowledge/kb.md +++ b/docs/knowledge/kb.md @@ -254,10 +254,10 @@ bl knowledge delete --index-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ------------ | -| `--index-id ` | string | 是 | 知识库 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--index-id ` | string | 是 | 知识库 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。 - **不可逆操作**:知识库及所有索引内容被永久删除。 - 数据中心中的源文件不受影响,仅删除知识库索引。 -- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要。 +- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求。 **示例** diff --git a/docs/knowledge/knowledge-cli-guide.md b/docs/knowledge/knowledge-cli-guide.md index 5080938..1f1f618 100644 --- a/docs/knowledge/knowledge-cli-guide.md +++ b/docs/knowledge/knowledge-cli-guide.md @@ -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 模式 diff --git a/docs/knowledge/service.md b/docs/knowledge/service.md index a4cb2d6..321af80 100644 --- a/docs/knowledge/service.md +++ b/docs/knowledge/service.md @@ -270,11 +270,11 @@ bl knowledge service deploy --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------------- | ------ | ---- | ---------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--version-desc ` | string | 否 | 新版本的描述说明 | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--version-desc ` | string | 否 | 新版本的描述说明 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -319,10 +319,10 @@ bl knowledge service delete --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | --------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/chunk.md b/docs/kscli/chunk.md index e75b869..ee97c34 100644 --- a/docs/kscli/chunk.md +++ b/docs/kscli/chunk.md @@ -213,7 +213,7 @@ kscli chunk delete --index-id --chunk-id [flags] | ----------------- | ------ | ---- | ------------------------------------------------ | | `--index-id ` | string | 是 | 知识库 ID | | `--chunk-id ` | array | 是 | Chunk ID(可重复,每批最多 10 个,超出自动分批) | -| `--yes` | switch | 否 | 跳过确认提示 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/collection-category.md b/docs/kscli/collection-category.md index b4061c4..898fe6a 100644 --- a/docs/kscli/collection-category.md +++ b/docs/kscli/collection-category.md @@ -232,10 +232,10 @@ kscli category delete --category-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| -------------------- | ------ | ---- | ------------ | -| `--category-id ` | string | 是 | 分类 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| -------------------- | ------ | ---- | ---------------------- | +| `--category-id ` | string | 是 | 分类 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/file.md b/docs/kscli/file.md index e4edce0..dda80b0 100644 --- a/docs/kscli/file.md +++ b/docs/kscli/file.md @@ -120,10 +120,10 @@ kscli file delete --file-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ---------------- | ------ | ---- | --------------- | -| `--file-id ` | string | 是 | 数据中心文件 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ---------------- | ------ | ---- | ---------------------- | +| `--file-id ` | string | 是 | 数据中心文件 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/docs/kscli/kb.md b/docs/kscli/kb.md index 5fa4c34..802ca03 100644 --- a/docs/kscli/kb.md +++ b/docs/kscli/kb.md @@ -254,10 +254,10 @@ kscli kb delete --index-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ------------ | -| `--index-id ` | string | 是 | 知识库 ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--index-id ` | string | 是 | 知识库 ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -275,7 +275,7 @@ json 模式:返回 API 原始响应。 - **不可逆操作**:知识库及所有索引内容被永久删除。 - 数据中心中的源文件不受影响,仅删除知识库索引。 -- 不带 `--yes` 时,CLI 会先查询知识库名称和文档数量作为确认摘要。 +- 不带 `--yes` 时,runtime 会在调用知识库业务 API、执行删除前返回确认请求。 **示例** diff --git a/docs/kscli/kscli-cli-guide.md b/docs/kscli/kscli-cli-guide.md index c9c7e6d..fdaf859 100644 --- a/docs/kscli/kscli-cli-guide.md +++ b/docs/kscli/kscli-cli-guide.md @@ -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 模式 diff --git a/docs/kscli/service.md b/docs/kscli/service.md index 42bf09c..85a09d2 100644 --- a/docs/kscli/service.md +++ b/docs/kscli/service.md @@ -270,11 +270,11 @@ kscli service deploy --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------------- | ------ | ---- | ---------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--version-desc ` | string | 否 | 新版本的描述说明 | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--version-desc ` | string | 否 | 新版本的描述说明 | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** @@ -319,10 +319,10 @@ kscli service delete --agent-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | --------------- | -| `--agent-id ` | string | 是 | 服务(agent)ID | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ---------------------- | +| `--agent-id ` | string | 是 | 服务(agent)ID | +| `--yes` | switch | 否 | 显式确认执行高风险操作 | **输出** diff --git a/packages/cli/doc-commands.md b/packages/cli/doc-commands.md index f2f57b9..f628e37 100644 --- a/packages/cli/doc-commands.md +++ b/packages/cli/doc-commands.md @@ -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 — 批量更新文档标签 diff --git a/packages/commands/src/commands/knowledge/category-delete.ts b/packages/commands/src/commands/knowledge/category-delete.ts index 3c94e9f..cc208d2 100644 --- a/packages/commands/src/commands/knowledge/category-delete.ts +++ b/packages/commands/src/commands/knowledge/category-delete.ts @@ -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 [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 | undefined> >({ diff --git a/packages/commands/src/commands/knowledge/chunk-delete.ts b/packages/commands/src/commands/knowledge/chunk-delete.ts index e895936..5afc266 100644 --- a/packages/commands/src/commands/knowledge/chunk-delete.ts +++ b/packages/commands/src/commands/knowledge/chunk-delete.ts @@ -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 --chunk-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) { diff --git a/packages/commands/src/commands/knowledge/file-delete.ts b/packages/commands/src/commands/knowledge/file-delete.ts index f73b94c..7242d38 100644 --- a/packages/commands/src/commands/knowledge/file-delete.ts +++ b/packages/commands/src/commands/knowledge/file-delete.ts @@ -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 { - let infoPart = ""; - try { - const detail = await client.requestJson({ - 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 [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 | undefined> >({ diff --git a/packages/commands/src/commands/knowledge/kb-delete.ts b/packages/commands/src/commands/knowledge/kb-delete.ts index 945173e..03bc903 100644 --- a/packages/commands/src/commands/knowledge/kb-delete.ts +++ b/packages/commands/src/commands/knowledge/kb-delete.ts @@ -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[0] }, - workspaceId: string, - indexId: string, -): Promise { - 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({ - 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 [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({ path: endpoint, method: "POST", diff --git a/packages/commands/src/commands/knowledge/service-delete.ts b/packages/commands/src/commands/knowledge/service-delete.ts index a4af5a6..027e2d6 100644 --- a/packages/commands/src/commands/knowledge/service-delete.ts +++ b/packages/commands/src/commands/knowledge/service-delete.ts @@ -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": "服务(Agent)ID" }, 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 { - let infoPart = ""; - let liveWarning = ""; - try { - const detail = await client.requestJson({ - 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 [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({ path: endpoint, method: "POST", diff --git a/packages/commands/src/commands/knowledge/service-deploy.ts b/packages/commands/src/commands/knowledge/service-deploy.ts index 6a0dc9c..38ced57 100644 --- a/packages/commands/src/commands/knowledge/service-deploy.ts +++ b/packages/commands/src/commands/knowledge/service-deploy.ts @@ -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 { - let infoPart = ""; - let editedWarning = ""; - try { - const detail = await client.requestJson({ - 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 [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({ path: endpoint, method: "POST", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts index ef3e4a6..a1f7089 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-chunk-category-file.e2e.test.ts @@ -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(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(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 () => { diff --git a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts index 04629e7..a1637ac 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-kb-delete.e2e.test.ts @@ -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"), + }, + }); }); }); diff --git a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts index 5f60768..0a54af9 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-service.e2e.test.ts @@ -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(stdout); + expect(data.endpoint).toMatch(/rag\/app\/delete/); + expect(data.request?.agent_id).toBe("aid_test"); }); }); diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index 5906a16..f6c0db9 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -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; + return risk.level === "high" && isLocalizedText(risk.message); +} + function assertCommand(path: string, value: unknown): asserts value is CommandPackCommand { 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).`); diff --git a/packages/runtime/src/confirm.ts b/packages/runtime/src/confirm.ts index 29e1680..cfe39e1 100644 --- a/packages/runtime/src/confirm.ts +++ b/packages/runtime/src/confirm.ts @@ -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 { - 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": diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 215ec10..ce41f46 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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"; diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts index 37e3d9b..d024db3 100644 --- a/packages/runtime/tests/command-packs.test.ts +++ b/packages/runtime/tests/command-packs.test.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( diff --git a/packages/runtime/tests/confirm.test.ts b/packages/runtime/tests/confirm.test.ts index d487c23..0eb9e2e 100644 --- a/packages/runtime/tests/confirm.test.ts +++ b/packages/runtime/tests/confirm.test.ts @@ -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", () => { diff --git a/packages/runtime/tests/public-api.test.ts b/packages/runtime/tests/public-api.test.ts deleted file mode 100644 index a03ea26..0000000 --- a/packages/runtime/tests/public-api.test.ts +++ /dev/null @@ -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"); -}); diff --git a/skills/bailian-cli/reference/knowledge.md b/skills/bailian-cli/reference/knowledge.md index 9189149..44056a4 100644 --- a/skills/bailian-cli/reference/knowledge.md +++ b/skills/bailian-cli/reference/knowledge.md @@ -94,8 +94,8 @@ bl knowledge category add --name sub --parent-id cate-xxx | Flag | Type | Required | Description | | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--category-id ` | string | yes | Category ID to delete | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-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 ` | string | yes | Knowledge base ID | | `--chunk-id ` | array | yes | Chunk ID to delete (repeatable; batches of 10 are sent automatically) | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-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 ` | string | yes | Knowledge base ID | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-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 ` | string | yes | Data-center file ID to delete | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-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 ` | string | yes | Service (agent) ID | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL | @@ -1080,8 +1080,8 @@ bl knowledge service delete --agent-id aid-xxx --yes | ----------------------- | ------ | -------- | --------------------------------------------------------------- | | `--agent-id ` | string | yes | Service (agent) ID | | `--version-desc ` | string | no | Description for the newly published version | -| `--yes` | switch | no | Skip the confirmation prompt | | `--workspace-id ` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) | +| `--yes` | switch | no | Confirm this high-risk operation | | `--api-key ` | string | no | API key | | `--base-url ` | string | no | API base URL |