diff --git a/docs/agents/cli-e2e-tests.md b/docs/agents/cli-e2e-tests.md index cfae077..554dbda 100644 --- a/docs/agents/cli-e2e-tests.md +++ b/docs/agents/cli-e2e-tests.md @@ -79,6 +79,13 @@ describe.skipIf()("e2e: (DashScope …)", () => { 3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本 4. **真实集成**:放在 skip 块**末尾** +高风险命令额外要求: + +- `--help` 展示 runtime 注入的 `--yes` +- 无 `--yes` 返回 exit code 7 和 JSON `type: "requires_confirmation"` +- `--dry-run` 无需 `--yes`,且必须证明在任何远端请求或本地写入之前返回 +- runtime 的离线 high-risk fixture 必须覆盖带 `--yes` 确实进入 `run()`,并断言 `yes` 不进入 command 自有 flags + ## Journey 层(用户旅程全链路) - **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复 diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index bbea87b..0d8ea2c 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -71,7 +71,9 @@ packages/commands/src/index.ts - `usageArgs`(不含 bin/path 前缀) - `exampleArgs`(不含 bin/path 前缀) - `validate`(跨 flag 校验) + - 高风险命令必须声明 `risk: { level: "high", message: <双语文案> }`;`--yes` 由 runtime 注入,命令不得自行声明 - 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client` + - 声明 `risk` 的 `run(ctx)` 必须在任何远端请求或本地写入之前处理 `ctx.settings.dryRun` 并返回预览;runtime 只负责确认闸门,不替命令实现 dry-run - `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令 - `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口 - [ ] 用户可见 Help 文案在命令文件中就近提供 `en-US` / `zh-CN`:命令 `description`、flag `description`、`notes` 和包含自然语言的 `exampleArgs`;纯命令语法示例可保留为字符串,服务端错误不翻译 diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index 9838e92..7f43767 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -17,11 +17,12 @@ │ ├─ ~/.bailian/telemetry.jsonl │ └─ AEM(pid=bailian-cli-node, event name=命令路径) │ - └─ authStage - ├─ apiKey → DashScope / 模型域 - ├─ console → Bailian Console Gateway - ├─ openapi → 阿里云 OpenAPI - └─ none → 无凭证域;本地命令也仍有 AEM 命令事件 + └─ confirmationStage + └─ versionCheckStage → authStage + ├─ apiKey → DashScope / 模型域 + ├─ console → Bailian Console Gateway + ├─ openapi → 阿里云 OpenAPI + └─ none → 无凭证域;本地命令也仍有 AEM 命令事件 ``` ### 1. 三套鉴权与埋点标识 @@ -77,7 +78,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 ### 3. 全命令 AEM 客户端埋点 -`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。 +`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹确认闸门、`authStage` 与命令执行,因此成功、确认未通过、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。确认闸门仍位于版本检查、鉴权和业务执行之前,不会因为埋点而放行高风险操作。 以下情况不会形成命令事件,因为没有进入 middleware 的 `run`: @@ -92,7 +93,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 - `command`、`timestamp`、`durationMs`、`success` - `cliVersion`、`nodeVersion`、`os` - `authMethod` -- 失败时的 `errorMessage`、`httpStatus`、`requestId` +- 失败时的 `errorMessage`、`exitCode`、`httpStatus`、`requestId` - 安全 allowlist 过滤后的 `params` 参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。 @@ -108,16 +109,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 AEM 映射: -| AEM 字段 | 内容 | -| ---------- | ----------------------------------------- | -| event name | 命令路径 | -| `et` | `EXP` | -| `ext` | 除 `command`、`params` 外的结构化事件字段 | -| `c1` | allowlist 参数 | -| `c2` | `success` / `failure` | -| `c3` | HTTP status | -| `c4` | 错误文案,最多 500 字符 | -| `c5` | request ID | +| AEM 字段 | 内容 | +| ---------- | ------------------------------------------------------------------ | +| event name | 命令路径 | +| `et` | `EXP` | +| `ext` | 除 `command`、`params` 外的结构化事件字段,包含失败时的 `exitCode` | +| `c1` | allowlist 参数 | +| `c2` | `success` / `failure` | +| `c3` | HTTP status | +| `c4` | 错误文案,最多 500 字符 | +| `c5` | request ID | 远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。 @@ -144,6 +145,7 @@ AEM 映射: - [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射 - [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径 - [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码 +- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5` - [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口 - [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径 diff --git a/docs/knowledge/doc.md b/docs/knowledge/doc.md index 5689d48..714fdf0 100644 --- a/docs/knowledge/doc.md +++ b/docs/knowledge/doc.md @@ -190,11 +190,11 @@ bl knowledge doc delete --index-id --doc-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ----------------- | -| `--index-id ` | string | 是 | 知识库 ID | -| `--doc-id ` | array | 是 | 文档 ID(可重复) | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ------------------ | +| `--index-id ` | string | 是 | 知识库 ID | +| `--doc-id ` | array | 是 | 文档 ID(可重复) | +| `--yes` | switch | 否 | 显式确认高风险操作 | **输出** @@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID # 删除单个文档 bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx -# 批量删除,跳过确认 +# 用户明确确认后批量删除 bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes ``` diff --git a/docs/kscli/doc.md b/docs/kscli/doc.md index 60cea67..6b024ec 100644 --- a/docs/kscli/doc.md +++ b/docs/kscli/doc.md @@ -190,11 +190,11 @@ kscli doc delete --index-id --doc-id [flags] **参数** -| 参数 | 类型 | 必填 | 说明 | -| ----------------- | ------ | ---- | ----------------- | -| `--index-id ` | string | 是 | 知识库 ID | -| `--doc-id ` | array | 是 | 文档 ID(可重复) | -| `--yes` | switch | 否 | 跳过确认提示 | +| 参数 | 类型 | 必填 | 说明 | +| ----------------- | ------ | ---- | ------------------ | +| `--index-id ` | string | 是 | 知识库 ID | +| `--doc-id ` | array | 是 | 文档 ID(可重复) | +| `--yes` | switch | 否 | 显式确认高风险操作 | **输出** @@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID # 删除单个文档 kscli doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx -# 批量删除,跳过确认 +# 用户明确确认后批量删除 kscli doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes ``` diff --git a/packages/cli/tests/e2e/command-packs.e2e.test.ts b/packages/cli/tests/e2e/command-packs.e2e.test.ts index 0ee79be..f2e740b 100644 --- a/packages/cli/tests/e2e/command-packs.e2e.test.ts +++ b/packages/cli/tests/e2e/command-packs.e2e.test.ts @@ -53,6 +53,7 @@ describe("e2e: Command Pack", () => { expect(linkedJson.linked.commands).toEqual([ "agent credential", "agent credential-denied", + "agent dangerous", "agent fail", "agent output", "agent ping", @@ -96,6 +97,34 @@ describe("e2e: Command Pack", () => { expect(failed.stderr).toContain("Use agent fail only in tests."); }); + test("high-risk 命令由 runtime 统一确认并支持安全 dry-run", async () => { + const dangerousHelp = await runCli(["agent", "dangerous", "--help"], env()); + expect(dangerousHelp.exitCode, dangerousHelp.stderr).toBe(0); + expect(dangerousHelp.stderr).toContain("--yes"); + + const unconfirmed = await runCli(["agent", "dangerous", "--output", "json"], env()); + expect(unconfirmed.exitCode).toBe(7); + expect(JSON.parse(unconfirmed.stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + + const confirmed = await runCli(["agent", "dangerous", "--yes", "--output", "json"], env()); + expect(confirmed.exitCode, confirmed.stderr).toBe(0); + expect(parseStdoutJson(confirmed.stdout)).toEqual({ + executed: true, + dry_run: false, + command_flags: [], + }); + + const preview = await runCli(["agent", "dangerous", "--dry-run", "--output", "json"], env()); + expect(preview.exitCode, preview.stderr).toBe(0); + expect(parseStdoutJson(preview.stdout)).toEqual({ + executed: false, + dry_run: true, + command_flags: [], + }); + }); + test("plugin list 输出加载状态", async () => { const result = await runCli(["plugin", "list", "--output", "json"], env()); expect(result.exitCode, result.stderr).toBe(0); @@ -109,6 +138,7 @@ describe("e2e: Command Pack", () => { commands: [ "agent credential", "agent credential-denied", + "agent dangerous", "agent fail", "agent output", "agent ping", diff --git a/packages/cli/tests/fixtures/command-pack/commands.mjs b/packages/cli/tests/fixtures/command-pack/commands.mjs index 698c1b3..04a0488 100644 --- a/packages/cli/tests/fixtures/command-pack/commands.mjs +++ b/packages/cli/tests/fixtures/command-pack/commands.mjs @@ -19,6 +19,26 @@ const ping = { }, }; +const dangerous = { + description: "Exercise runtime confirmation for a high-risk Command Pack command", + auth: "none", + risk: { + level: "high", + message: { + "en-US": "This fixture represents a high-risk operation.", + "zh-CN": "该测试命令代表高风险操作。", + }, + }, + async run(ctx) { + const dryRun = ctx.settings.dryRun; + ctx.output.result({ + executed: !dryRun, + dry_run: dryRun, + command_flags: Object.keys(ctx.flags), + }); + }, +}; + const credential = { description: "Read an API key through the Command Pack host adapter", auth: "apiKey", @@ -55,6 +75,7 @@ const fail = { export default { "agent credential": credential, "agent credential-denied": credentialDenied, + "agent dangerous": dangerous, "agent fail": fail, "agent output": output, "agent ping": ping, diff --git a/packages/commands/src/commands/knowledge/doc-delete.ts b/packages/commands/src/commands/knowledge/doc-delete.ts index 915b9e7..d424f27 100644 --- a/packages/commands/src/commands/knowledge/doc-delete.ts +++ b/packages/commands/src/commands/knowledge/doc-delete.ts @@ -6,7 +6,7 @@ import { type FlagsDef, type RagDeleteFileResponse, } 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 DOC_DELETE_FLAGS = { @@ -25,28 +25,22 @@ const DOC_DELETE_FLAGS = { }, required: true, }, - yes: { - type: "switch", - description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" }, - }, ...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: { "en-US": "Delete documents and their chunks from a knowledge base", "zh-CN": "从知识库中删除文档及其 Chunk", }, auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the selected documents and all of their chunks.", + "zh-CN": "该操作会永久删除所选文档及其全部 Chunk,且无法撤销。", + }, + }, usageArgs: "--index-id --doc-id [flags]", flags: DOC_DELETE_FLAGS, notes: [ @@ -72,7 +66,7 @@ export default defineCommand({ }, ], exampleArgs: [ - "--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx", + "--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run", "--index-id idx-xxx --doc-id file-a --doc-id file-b --yes", ], async run(ctx) { @@ -89,11 +83,6 @@ export default defineCommand({ return; } - await confirmDangerousAction( - buildDeleteSummary(flags.indexId, flags.docId), - flags.yes ?? false, - ); - const response = await ctx.client.requestJson({ path: endpoint, method: "POST", diff --git a/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts b/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts index dba73e3..53c0a90 100644 --- a/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts +++ b/packages/commands/tests/e2e/knowledge/knowledge-doc-delete.e2e.test.ts @@ -66,7 +66,7 @@ describe("e2e: knowledge doc delete", () => { expect(data.request?.doc_ids).toEqual(["file_a", "file_b"]); }); - test("非 TTY 无 --yes 报 USAGE (2)", async () => { + test("无 --yes 返回确认请求 (7)", async () => { const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [ "knowledge", "doc", @@ -79,9 +79,18 @@ describe("e2e: knowledge doc 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"), + }, + }); + expect(stderr).not.toContain("sk-fake"); }); }); diff --git a/packages/core/src/errors/codes.ts b/packages/core/src/errors/codes.ts index 83e4f8b..f0c4400 100644 --- a/packages/core/src/errors/codes.ts +++ b/packages/core/src/errors/codes.ts @@ -6,6 +6,7 @@ export const ExitCode = { QUOTA: 4, TIMEOUT: 5, NETWORK: 6, + CONFIRMATION_REQUIRED: 7, CONTENT_FILTER: 10, } as const; diff --git a/packages/core/src/telemetry/event.ts b/packages/core/src/telemetry/event.ts index 25adf84..228bf69 100644 --- a/packages/core/src/telemetry/event.ts +++ b/packages/core/src/telemetry/event.ts @@ -6,6 +6,7 @@ export interface TrackingEvent { durationMs: number; success: boolean; errorMessage?: string; + exitCode?: number; httpStatus?: number; requestId?: string; cliVersion: string; @@ -19,7 +20,7 @@ export function createTrackingEvent(opts: { command: string; durationMs: number; success: boolean; - error?: { message?: string; httpStatus?: number; requestId?: string }; + error?: { message?: string; exitCode?: number; httpStatus?: number; requestId?: string }; cliVersion: string; authMethod?: AuthRequirement; params?: Record; @@ -40,6 +41,7 @@ export function createTrackingEvent(opts: { if (!opts.success && opts.error) { if (opts.error.message) event.errorMessage = opts.error.message; + if (opts.error.exitCode !== undefined) event.exitCode = opts.error.exitCode; if (opts.error.httpStatus !== undefined) event.httpStatus = opts.error.httpStatus; if (opts.error.requestId) event.requestId = opts.error.requestId; } diff --git a/packages/core/src/telemetry/tracker.ts b/packages/core/src/telemetry/tracker.ts index aace9df..0335d3a 100644 --- a/packages/core/src/telemetry/tracker.ts +++ b/packages/core/src/telemetry/tracker.ts @@ -103,6 +103,7 @@ export async function trackCommandExecution( const start = performance.now(); let success = true; let errorMessage: string | undefined; + let exitCode: number | undefined; let httpStatus: number | undefined; let requestId: string | undefined; @@ -112,6 +113,7 @@ export async function trackCommandExecution( success = false; if (err instanceof BailianError) { errorMessage = err.message; + exitCode = err.exitCode; httpStatus = err.api?.httpStatus; requestId = err.api?.requestId; } else if (err instanceof Error) { @@ -125,7 +127,7 @@ export async function trackCommandExecution( command: commandPath.join(" "), durationMs, success, - error: success ? undefined : { message: errorMessage, httpStatus, requestId }, + error: success ? undefined : { message: errorMessage, exitCode, httpStatus, requestId }, cliVersion: deps.identity.version, authMethod: deps.authMethod, params: extractParams(flags), diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 95e524d..66b6268 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -258,10 +258,24 @@ export interface CommandContext { * typed flags (`ParsedFlags` = 命令自有 flag). Stored heterogeneously as * {@link AnyCommand}; the precise typing lives at the `defineCommand` call site. */ +export type CommandRiskLevel = "high"; + +export interface CommandRisk { + level: CommandRiskLevel; + message: LocalizedText; +} + export interface Command { description: LocalizedText; /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; + /** + * Runtime-classified operation risk and its user-facing consequence message. + * Omit for normal commands. + * High-risk commands must return from `run` on `settings.dryRun` before any + * remote request or local write; runtime only owns the confirmation gate. + */ + risk?: CommandRisk; /** Usage line arg portion, e.g. "--prompt [flags]". Manually written. */ usageArgs?: string; /** Example args (without the ` ` prefix). */ diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index b617bdd..cf240e3 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,5 +1,7 @@ export type { Command, + CommandRisk, + CommandRiskLevel, AnyCommand, CommandContext, LocalizedText, diff --git a/packages/core/tests/command-types.test.ts b/packages/core/tests/command-types.test.ts new file mode 100644 index 0000000..617979e --- /dev/null +++ b/packages/core/tests/command-types.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "vite-plus/test"; +import { defineCommand, type CommandPack, type CommandRiskLevel } from "../src/index.ts"; + +const noopRun = async () => {}; + +test("high-risk commands keep level and message in one typed object", () => { + const command = defineCommand({ + description: "danger", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + run: noopRun, + }); + const pack = { + "agent dangerous": { + description: "danger", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + run: noopRun, + }, + } satisfies CommandPack; + + expect(command.risk).toEqual({ level: "high", message: "dangerous operation" }); + expect(pack["agent dangerous"].risk.level).toBe("high"); +}); + +test("risk types reject flat or incomplete declarations", () => { + const high = "high" satisfies CommandRiskLevel; + // @ts-expect-error unsupported levels must be added to CommandRiskLevel first. + const low = "low" satisfies CommandRiskLevel; + + defineCommand({ + description: "danger", + auth: "none", + // @ts-expect-error high-risk metadata requires a message. + risk: { level: "high" }, + run: noopRun, + }); + defineCommand({ + description: "danger", + auth: "none", + // @ts-expect-error risk metadata is a single object, not a flat level. + risk: "high", + run: noopRun, + }); + + expect(high).toBe("high"); + expect(low).toBe("low"); +}); diff --git a/packages/core/tests/telemetry-tracker.test.ts b/packages/core/tests/telemetry-tracker.test.ts new file mode 100644 index 0000000..6839711 --- /dev/null +++ b/packages/core/tests/telemetry-tracker.test.ts @@ -0,0 +1,54 @@ +import { expect, test, vi } from "vite-plus/test"; +import type { Identity, Settings } from "../src/config/schema.ts"; +import { BailianError } from "../src/errors/base.ts"; +import { ExitCode } from "../src/errors/codes.ts"; +import { buildRemoteAemOptions, type TrackingEvent } from "../src/telemetry/event.ts"; + +const sinkMocks = vi.hoisted(() => ({ + localSink: vi.fn<(event: TrackingEvent) => Promise>(async () => {}), + remoteSink: vi.fn<(event: TrackingEvent) => Promise>(async () => {}), +})); + +vi.mock("../src/telemetry/sink.ts", () => sinkMocks); + +import { trackCommandExecution } from "../src/telemetry/tracker.ts"; + +const identity: Identity = { + binName: "bl", + version: "0.0.0-test", + clientName: "bailian-cli-test", + npmPackage: "bailian-cli", +}; + +test("records BailianError exitCode in local events and AEM ext", async () => { + sinkMocks.localSink.mockClear(); + sinkMocks.remoteSink.mockClear(); + const error = new BailianError("该操作会永久删除文档。", ExitCode.CONFIRMATION_REQUIRED); + + await expect( + trackCommandExecution( + { + identity, + settings: { telemetry: true } as Settings, + authMethod: "apiKey", + }, + ["knowledge", "doc", "delete"], + {}, + async () => { + throw error; + }, + ), + ).rejects.toBe(error); + + expect(sinkMocks.localSink).toHaveBeenCalledOnce(); + const event = sinkMocks.localSink.mock.calls[0]![0]; + expect(event).toMatchObject({ + command: "knowledge doc delete", + success: false, + exitCode: 7, + errorMessage: "该操作会永久删除文档。", + }); + expect(buildRemoteAemOptions(event)).toMatchObject({ + ext: expect.objectContaining({ exitCode: 7 }), + }); +}); diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts index 79e47d4..5906a16 100644 --- a/packages/runtime/src/command-packs/validate.ts +++ b/packages/runtime/src/command-packs/validate.ts @@ -90,6 +90,9 @@ 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 (typeof command.run !== "function") { throw new Error(`Command "${path}" is missing run(ctx).`); } @@ -106,6 +109,7 @@ function adaptCommandPack( { description: command.description, auth: command.auth, + risk: command.risk, usageArgs: command.usageArgs, exampleArgs: command.exampleArgs, notes: command.notes, diff --git a/packages/runtime/src/confirm.ts b/packages/runtime/src/confirm.ts index 43cf6c6..29e1680 100644 --- a/packages/runtime/src/confirm.ts +++ b/packages/runtime/src/confirm.ts @@ -1,14 +1,34 @@ -// 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"; +import { + BailianError, + ExitCode, + type CommandRisk, + type FlagsDef, + type LocalizedText, +} from "bailian-cli-core"; + +/** Runtime-owned flag: commands declare risk, never their own confirmation flag. */ +export const CONFIRMATION_FLAGS = { + yes: { + type: "switch", + description: { + "en-US": "Confirm this high-risk operation", + "zh-CN": "确认执行此高风险操作", + }, + }, +} satisfies FlagsDef; + +export function confirmationFlagDefs(command: { risk?: CommandRisk }): FlagsDef { + return command.risk === undefined ? {} : CONFIRMATION_FLAGS; +} /** - * - `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 + * 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; @@ -34,3 +54,35 @@ export async function confirmDangerousAction(summary: string, yes: boolean): Pro readline.close(); } } + +export function confirmationHint(): LocalizedText { + return { + "en-US": + "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + "zh-CN": "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }; +} + +interface ConfirmationRequiredErrorOptions { + message: string; + hint: string; +} + +/** Semantic runtime error consumed by both humans and Agent callers. */ +export class ConfirmationRequiredError extends BailianError { + constructor(options: ConfirmationRequiredErrorOptions) { + super(options.message, ExitCode.CONFIRMATION_REQUIRED, options.hint); + this.name = "ConfirmationRequiredError"; + } + + override toJSON() { + return { + error: { + code: this.exitCode, + type: "requires_confirmation", + message: this.message, + hint: this.hint, + }, + }; + } +} diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index be75345..767e0fc 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -6,10 +6,18 @@ import { authStage, telemetryStage, versionCheckStage, + confirmationStage, runCommandStage, type RunContext, } from "./middleware.ts"; -import type { AnyCommand, FlagsDef, Identity, ParsedFlags, SourceFlags } from "bailian-cli-core"; +import type { + AnyCommand, + FlagsDef, + Identity, + LocalizedText, + ParsedFlags, + SourceFlags, +} from "bailian-cli-core"; import { CONSOLE_AUTH_FLAGS, DEFAULT_LANGUAGE, @@ -33,6 +41,7 @@ import { loadCommandPacks } from "./command-packs/load.ts"; import { createCommandPackManager } from "./command-packs/manager.ts"; import type { CommandPackPolicy } from "./command-packs/types.ts"; import { createTranslator } from "./i18n.ts"; +import { confirmationFlagDefs } from "./confirm.ts"; /** Per-product identity injected by each CLI entrypoint (bl / rag / …). */ export interface CliOptions { @@ -113,7 +122,13 @@ export function createCli(commands: Record, opts: CliOptions installProcessHandlers(binName); - const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]); + const runMiddleware = compose([ + telemetryStage, + confirmationStage, + versionCheckStage, + authStage, + runCommandStage, + ]); function getLoadedCommandPacks(): ReturnType { if (!loadedCommandPacksPromise) { @@ -122,11 +137,17 @@ export function createCli(commands: Record, opts: CliOptions return loadedCommandPacksPromise; } - async function getRegistry(argv: string[]): Promise { + async function getRegistry(argv: string[]): Promise<{ + registry: CommandRegistry; + localize: (text: LocalizedText) => string; + }> { const localeSources = buildSources(pickConfigFlag(argv)); const translator = createTranslator(localeSources.file.language ?? DEFAULT_LANGUAGE); const loaded = await getLoadedCommandPacks(); - return new CommandRegistry(loaded.commands, binName, translator); + return { + registry: new CommandRegistry(loaded.commands, binName, translator), + localize: (text) => translator.localize(text), + }; } /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ @@ -157,7 +178,11 @@ export function createCli(commands: Record, opts: CliOptions } } - async function dispatch(registry: CommandRegistry, argv: string[]): Promise { + async function dispatch( + registry: CommandRegistry, + argv: string[], + localize: (text: LocalizedText) => string, + ): Promise { const res = resolve(argv, registry); switch (res.kind) { @@ -177,9 +202,11 @@ export function createCli(commands: Record, opts: CliOptions try { // 全局与凭证域 flag 进 sources,命令自有 flag 进 ctx.flags。 const credDefs = credentialFlagDefs(res.command); + const confirmationDefs = confirmationFlagDefs(res.command); const parsedFlags = parseFlags(res.rest, { ...GLOBAL_FLAGS, ...credDefs, + ...confirmationDefs, ...res.command.flags, }) as Record; const globalFlags = pick(parsedFlags, [ @@ -201,6 +228,8 @@ export function createCli(commands: Record, opts: CliOptions path: res.path, command: res.command, flags: ownFlags, + confirmed: parsedFlags.yes === true, + localize, settings, sources, configStore: makeConfigStore(sources.configName), @@ -229,7 +258,7 @@ export function createCli(commands: Record, opts: CliOptions run(argv: string[] = process.argv.slice(2)) { return Promise.resolve() .then(() => getRegistry(argv)) - .then((registry) => dispatch(registry, argv)) + .then(({ registry, localize }) => dispatch(registry, argv, localize)) .catch( (err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void, ); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index c1579a9..07f0d53 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -11,6 +11,7 @@ import type { ParsedFlags, ResolutionSources, Settings, + LocalizedText, } from "bailian-cli-core"; import { Client, @@ -28,6 +29,7 @@ import { performAutoUpdate, shouldAutoUpdate, } from "./utils/update-checker.ts"; +import { ConfirmationRequiredError, confirmationHint } from "./confirm.ts"; /** * What each middleware stage gets for the invocation in flight: the matched @@ -42,6 +44,10 @@ export interface RunContext { readonly command: AnyCommand; /** 只含本命令声明的 flag(分流后);全局 flag 在 sources/settings。 */ flags: ParsedFlags; + /** Whether the runtime-owned --yes flag was explicitly supplied. */ + readonly confirmed: boolean; + /** Locale selector for runtime-owned command metadata and messages. */ + readonly localize: (text: LocalizedText) => string; /** 解析后的有效配置面(命令的新读取面;双轨迁移期与 config 并存)。 */ settings: Settings; /** 解析源:provider/访问器用;业务命令不可见(窄视图类型不含此字段)。 */ @@ -162,5 +168,21 @@ export const versionCheckStage: Middleware = async (ctx, next) => { } }; +/** + * Safety gate before update/auth/command stages. Telemetry may wrap this stage + * so confirmation-required failures remain observable. + */ +export const confirmationStage: Middleware = async (ctx, next) => { + if (ctx.command.risk === undefined || ctx.confirmed || ctx.settings.dryRun) { + await next(); + return; + } + + throw new ConfirmationRequiredError({ + message: ctx.localize(ctx.command.risk.message), + hint: ctx.localize(confirmationHint()), + }); +}; + /** Innermost stage: hand control to the command with its full context. */ export const runCommandStage: Middleware = (ctx) => ctx.command.run(ctx); diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index 8bf89f7..4b2fbcf 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -17,6 +17,7 @@ import { camelToKebab } from "./args.ts"; import type { Translator } from "./i18n.ts"; import { printQuickStart, printWelcomeBanner } from "./output/banner.ts"; import { ansi } from "./output/color.ts"; +import { confirmationFlagDefs } from "./confirm.ts"; export type { Command, AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core"; @@ -101,7 +102,11 @@ export class CommandRegistry { private register(path: string, command: AnyCommand): void { // 同名守卫:命令自有 flag 不得与全局或其可见凭证域 flag 同名。 - const reserved = { ...GLOBAL_FLAGS, ...credentialFlagDefs(command) }; + const reserved = { + ...GLOBAL_FLAGS, + ...confirmationFlagDefs(command), + ...credentialFlagDefs(command), + }; for (const key of Object.keys(command.flags ?? {})) { if (key in reserved) { throw new Error(`Command "${path}" redeclares reserved flag "${key}".`); @@ -429,6 +434,7 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT. ); const flagEntries = [ ...Object.entries(cmd.flags ?? {}), + ...Object.entries(confirmationFlagDefs(cmd)), ...Object.entries(credentialFlagDefs(cmd)), ] as [string, FlagDef][]; if (flagEntries.length > 0) { diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts index fe227f4..37e3d9b 100644 --- a/packages/runtime/tests/command-packs.test.ts +++ b/packages/runtime/tests/command-packs.test.ts @@ -141,6 +141,7 @@ test("loads an API 1 Command Pack and preserves its command contract", async () expect(Object.keys(commands)).toEqual([ "agent credential", "agent credential-denied", + "agent dangerous", "agent fail", "agent output", "agent ping", @@ -151,6 +152,14 @@ test("loads an API 1 Command Pack and preserves its command contract", async () "en-US": "Ping the Command Pack fixture", "zh-CN": "调用 Command Pack 测试命令", }); + expect(commands["agent ping"]?.risk).toBeUndefined(); + expect(commands["agent dangerous"]?.risk).toEqual({ + level: "high", + message: { + "en-US": "This fixture represents a high-risk operation.", + "zh-CN": "该测试命令代表高风险操作。", + }, + }); expect(commands["agent ping"]?.flags?.message).toMatchObject({ required: true, type: "string" }); }); diff --git a/packages/runtime/tests/confirm.test.ts b/packages/runtime/tests/confirm.test.ts index e9fdba6..d487c23 100644 --- a/packages/runtime/tests/confirm.test.ts +++ b/packages/runtime/tests/confirm.test.ts @@ -1,25 +1,133 @@ -import { afterEach, describe, expect, test } from "vite-plus/test"; -import { ExitCode } from "bailian-cli-core"; -import { confirmDangerousAction } from "../src/confirm.ts"; +import { afterEach, 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 { 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(); }); -describe("confirmDangerousAction", () => { - test("--yes 时直接通过,不触碰 stdin", async () => { - await expect(confirmDangerousAction("Delete kb idx-1", true)).resolves.toBeUndefined(); +const HIGH_RISK_MESSAGE = { + "en-US": "This permanently deletes the document and its chunks.", + "zh-CN": "该操作会永久删除文档及其 Chunk,且无法撤销。", +} satisfies LocalizedText; + +function makeContext(options: { + risk?: CommandRisk; + confirmed?: boolean; + dryRun?: boolean; +}): RunContext { + const command = defineCommand({ + description: "Delete a document", + auth: "none", + risk: options.risk, + async run() {}, + }); + return { + identity: { + binName: "bl", + version: "0.0.0-test", + clientName: "bailian-cli-test", + npmPackage: "bailian-cli", + }, + path: ["knowledge", "doc", "delete"], + command, + flags: {}, + confirmed: options.confirmed ?? false, + localize: (text: LocalizedText) => (typeof text === "string" ? text : text["zh-CN"]), + settings: { dryRun: options.dryRun ?? false } as RunContext["settings"], + } as unknown as RunContext; +} + +describe("confirmation metadata", () => { + test("injects --yes only for high-risk commands", () => { + expect( + confirmationFlagDefs({ risk: { level: "high", message: HIGH_RISK_MESSAGE } }), + ).toHaveProperty("yes"); + expect(confirmationFlagDefs({})).toEqual({}); }); - test("非 TTY 且无 --yes 时抛 USAGE 并引导 --yes", async () => { + test("serializes the stable Agent-readable confirmation contract", () => { + const error = new ConfirmationRequiredError({ + message: HIGH_RISK_MESSAGE["zh-CN"], + hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }); + + expect(error.exitCode).toBe(ExitCode.CONFIRMATION_REQUIRED); + expect(error.toJSON()).toEqual({ + error: { + code: 7, + type: "requires_confirmation", + message: HIGH_RISK_MESSAGE["zh-CN"], + hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }, + }); + }); + + test("legacy commands fail closed without opening a TTY prompt", 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/); - } + 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", () => { + test("blocks high-risk execution without echoing the original command", async () => { + const next = vi.fn(async () => {}); + + const promise = confirmationStage( + makeContext({ risk: { level: "high", message: HIGH_RISK_MESSAGE } }), + next, + ); + await expect(promise).rejects.toMatchObject({ + exitCode: 7, + message: HIGH_RISK_MESSAGE["zh-CN"], + hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。", + }); + expect(next).not.toHaveBeenCalled(); + }); + + test.each([ + ["explicit --yes", { risk: { level: "high", message: HIGH_RISK_MESSAGE }, confirmed: true }], + ["dry-run", { risk: { level: "high", message: HIGH_RISK_MESSAGE }, dryRun: true }], + ["normal command", {}], + ] as const)("passes %s through", async (_label, options) => { + const next = vi.fn(async () => {}); + await confirmationStage(makeContext(options), next); + expect(next).toHaveBeenCalledOnce(); }); }); diff --git a/packages/runtime/tests/error-handler.test.ts b/packages/runtime/tests/error-handler.test.ts index a128657..a217e8c 100644 --- a/packages/runtime/tests/error-handler.test.ts +++ b/packages/runtime/tests/error-handler.test.ts @@ -1,6 +1,7 @@ import { ExitCode } from "bailian-cli-core"; import { expect, test } from "vite-plus/test"; import { handleError } from "../src/error-handler.ts"; +import { ConfirmationRequiredError } from "../src/confirm.ts"; test("handleError: fetch failed JSON includes cause.code from errno", () => { const previousOutput = process.env.DASHSCOPE_OUTPUT; @@ -8,7 +9,7 @@ test("handleError: fetch failed JSON includes cause.code from errno", () => { let stderr = ""; const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit; + const originalExit = process.exit.bind(process); process.stderr.write = ((chunk: string | Uint8Array) => { stderr += String(chunk); return true; @@ -52,7 +53,7 @@ test("handleError: fetch failed without nested cause still maps to NETWORK", () let stderr = ""; const originalWrite = process.stderr.write.bind(process.stderr); - const originalExit = process.exit; + const originalExit = process.exit.bind(process); process.stderr.write = ((chunk: string | Uint8Array) => { stderr += String(chunk); return true; @@ -83,3 +84,42 @@ test("handleError: fetch failed without nested cause still maps to NETWORK", () } } }); + +test("handleError: confirmation text uses the standard message and hint layout", () => { + const previousOutput = process.env.DASHSCOPE_OUTPUT; + delete process.env.DASHSCOPE_OUTPUT; + + let stderr = ""; + const originalWrite = process.stderr.write.bind(process.stderr); + const originalExit = process.exit.bind(process); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + process.exit = ((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as typeof process.exit; + + const confirmation = new ConfirmationRequiredError({ + message: "This permanently deletes the selected documents and all of their chunks.", + hint: "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + }); + + try { + expect(() => handleError(confirmation, "bl")).toThrow( + new RegExp(`process\\.exit:${ExitCode.CONFIRMATION_REQUIRED}`), + ); + expect(stderr).toContain( + "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.", + ); + expect(stderr).not.toContain("bl knowledge doc delete"); + expect(stderr).not.toContain("Risk:"); + expect(stderr).not.toContain("Action:"); + expect(stderr).not.toContain("Note:"); + } finally { + process.stderr.write = originalWrite; + process.exit = originalExit; + if (previousOutput === undefined) delete process.env.DASHSCOPE_OUTPUT; + else process.env.DASHSCOPE_OUTPUT = previousOutput; + } +}); diff --git a/packages/runtime/tests/public-api.test.ts b/packages/runtime/tests/public-api.test.ts new file mode 100644 index 0000000..a03ea26 --- /dev/null +++ b/packages/runtime/tests/public-api.test.ts @@ -0,0 +1,11 @@ +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/packages/runtime/tests/registry-guard.test.ts b/packages/runtime/tests/registry-guard.test.ts index cdc270e..87c1e21 100644 --- a/packages/runtime/tests/registry-guard.test.ts +++ b/packages/runtime/tests/registry-guard.test.ts @@ -33,3 +33,49 @@ test("命令重声明其可见域的凭证 flag → 抛错;不可见域的同名 }); expect(() => new CommandRegistry({ "x y": modelCmd }, "bl")).not.toThrow(); }); + +test("high risk 命令不能自行声明 runtime 保留的 yes", () => { + const high = defineCommand({ + description: "test", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + flags: { yes: { type: "switch", description: "duplicate" } }, + run: noopRun, + }); + const normal = defineCommand({ + description: "test", + auth: "none", + flags: { yes: { type: "switch", description: "command-owned" } }, + run: noopRun, + }); + + expect(() => new CommandRegistry({ "x high": high }, "bl")).toThrow(/yes/); + expect(() => new CommandRegistry({ "x normal": normal }, "bl")).not.toThrow(); +}); + +test("命令 help 只为 high risk 展示 runtime 注入的 --yes", () => { + const high = defineCommand({ + description: "danger", + auth: "none", + risk: { level: "high", message: "dangerous operation" }, + run: noopRun, + }); + const normal = defineCommand({ + description: "safe", + auth: "none", + run: noopRun, + }); + const registry = new CommandRegistry({ "asset delete": high, "asset list": normal }, "bl"); + + let highHelp = ""; + let normalHelp = ""; + registry.printHelp(["asset", "delete"], { + write: (chunk: string) => (highHelp += chunk), + } as unknown as NodeJS.WriteStream); + registry.printHelp(["asset", "list"], { + write: (chunk: string) => (normalHelp += chunk), + } as unknown as NodeJS.WriteStream); + + expect(highHelp).toContain("--yes"); + expect(normalHelp).not.toContain("--yes"); +}); diff --git a/skills/bailian-cli/reference/knowledge.md b/skills/bailian-cli/reference/knowledge.md index 1fc2aab..9189149 100644 --- a/skills/bailian-cli/reference/knowledge.md +++ b/skills/bailian-cli/reference/knowledge.md @@ -508,8 +508,8 @@ bl knowledge delete --index-id idx-xxx --yes | --------------------- | ------ | -------- | --------------------------------------------------------------- | | `--index-id ` | string | yes | Knowledge base ID | | `--doc-id ` | array | yes | Document ID to delete (repeatable) | -| `--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 | @@ -523,7 +523,7 @@ bl knowledge delete --index-id idx-xxx --yes #### Examples ```bash -bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx +bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run ``` ```bash diff --git a/tools/generate-reference.ts b/tools/generate-reference.ts index 98f2c6b..d1ad007 100644 --- a/tools/generate-reference.ts +++ b/tools/generate-reference.ts @@ -29,6 +29,7 @@ import { type LocalizedText, } from "../packages/core/src/index.ts"; import { commands } from "../packages/cli/src/commands.ts"; +import { confirmationFlagDefs } from "../packages/runtime/src/confirm.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SKILLS_DIR = join(__dirname, "../skills"); @@ -160,7 +161,13 @@ function commandSection(path: string, cmd: AnyCommand): string { // 与命令 help 的 Flags 区一致:自有 + 该命令可见的凭证域 flag。 lines.push("#### Flags", ""); - lines.push(formatFlagsTable({ ...cmd.flags, ...credentialFlagDefs(cmd) })); + lines.push( + formatFlagsTable({ + ...cmd.flags, + ...confirmationFlagDefs(cmd), + ...credentialFlagDefs(cmd), + }), + ); if (cmd.notes?.length) { lines.push("#### Notes", "");