From 91e6c6f553c3f4a05d6a7c00a2e02561d28d160c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Fri, 26 Jun 2026 16:50:12 +0800 Subject: [PATCH] refactor(runtime): resolve/middleware kernel + declarative arg validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 main 从一堆 if + process.exit 重构为「argv 解析成数据 → 交给统一管线执行」。 内核 - resolve(argv) → Resolution(version/help/run/usageError):路由即数据,dispatch 只 switch - compose 洋葱中间件 (versionCheck/telemetry/auth/runCommand),命令仍收 (config, flags) - registry.locate() 统一 leaf/group/unknown,取代 isGroupPath + 抛异常的 resolve - 删除 command-help.ts 全局可变单例:help 渲染收口到错误边界 错误模型 - 新增 UsageError(写错了 → exit 2) 与 IncompleteCommandError(没写完 → 打 help、exit 0) - version / help / onboarding / 组帮助统一由 resolve 产出、dispatch 分派 参数与校验 - parseFlags 重写:无 positional、新增 switch 类型、值/类型/重复校验 - 无条件必填 → 解析器声明式强制 (OptionDef.required) - 跨 flag / 条件约束 → 新增 command.validate(flags) 钩子 (text-chat / search-web / speech / vision / video-ref) - 移除全部交互式输入 (promptText/Select/Confirm),缺输入直接打 help 输出 - detectOutputFormat 默认 text,不再按 TTY 切 json 测试 - 删除 3 个 stale cli 测试,runtime 单测重写 (29 passed),e2e 适配新行为 --- packages/cli/tests/args.test.ts | 95 ------- packages/cli/tests/e2e/config.e2e.test.ts | 6 +- packages/cli/tests/e2e/knowledge.e2e.test.ts | 4 +- packages/cli/tests/e2e/mcp.e2e.test.ts | 26 +- packages/cli/tests/e2e/pipeline.e2e.test.ts | 22 +- packages/cli/tests/e2e/search-web.e2e.test.ts | 28 +- .../cli/tests/e2e/video-download.e2e.test.ts | 4 +- packages/cli/tests/e2e/video-edit.e2e.test.ts | 6 +- .../tests/e2e/video-generate-i2v.e2e.test.ts | 6 +- .../tests/e2e/video-generate-t2v.e2e.test.ts | 6 +- .../cli/tests/e2e/video-ref-r2v.e2e.test.ts | 6 +- packages/cli/tests/index.test.ts | 109 -------- packages/cli/tests/proxy.test.ts | 42 --- .../src/commands/advisor/recommend.ts | 32 +-- packages/commands/src/commands/app/call.ts | 4 - packages/commands/src/commands/auth/login.ts | 22 +- packages/commands/src/commands/config/set.ts | 17 +- .../commands/src/commands/console/call.ts | 4 - packages/commands/src/commands/file/upload.ts | 16 +- packages/commands/src/commands/image/edit.ts | 24 +- .../commands/src/commands/image/generate.ts | 26 +- .../src/commands/knowledge/retrieve.ts | 4 - packages/commands/src/commands/mcp/call.ts | 24 +- packages/commands/src/commands/mcp/tools.ts | 18 +- packages/commands/src/commands/memory/add.ts | 8 +- .../commands/src/commands/memory/delete.ts | 4 - packages/commands/src/commands/memory/list.ts | 2 - .../src/commands/memory/profile-create.ts | 5 - .../src/commands/memory/profile-get.ts | 4 - .../commands/src/commands/memory/search.ts | 8 +- .../commands/src/commands/memory/update.ts | 9 - packages/commands/src/commands/omni/chat.ts | 20 +- .../commands/src/commands/pipeline/run.ts | 23 +- .../src/commands/pipeline/validate.ts | 18 +- packages/commands/src/commands/search/web.ts | 19 +- .../commands/src/commands/speech/recognize.ts | 4 - .../src/commands/speech/synthesize.ts | 72 +----- packages/commands/src/commands/text/chat.ts | 25 +- .../commands/src/commands/usage/freetier.ts | 10 +- .../commands/src/commands/video/download.ts | 11 +- packages/commands/src/commands/video/edit.ts | 34 +-- .../commands/src/commands/video/generate.ts | 19 +- packages/commands/src/commands/video/ref.ts | 33 +-- .../commands/src/commands/video/task-get.ts | 6 +- .../commands/src/commands/vision/describe.ts | 34 +-- packages/core/src/errors/base.ts | 28 ++ packages/core/src/index.ts | 2 +- packages/core/src/output/formatter.ts | 3 - packages/core/src/types/command.ts | 38 ++- packages/core/src/types/flags.ts | 1 - packages/runtime/src/args.ts | 244 +++++++++--------- packages/runtime/src/create-cli.ts | 185 ++++++------- packages/runtime/src/index.ts | 22 +- packages/runtime/src/middleware.ts | 83 ++++++ packages/runtime/src/output/prompt.ts | 32 +-- packages/runtime/src/registry.ts | 81 +++--- packages/runtime/src/resolve.ts | 41 +++ packages/runtime/src/utils/command-help.ts | 25 -- packages/runtime/tests/args.test.ts | 177 ++++++++----- skills/bailian-cli/reference/advisor.md | 14 +- skills/bailian-cli/reference/config.md | 4 +- skills/bailian-cli/reference/image.md | 30 +-- skills/bailian-cli/reference/index.md | 34 +-- skills/bailian-cli/reference/mcp.md | 46 ++-- skills/bailian-cli/reference/pipeline.md | 45 ++-- skills/bailian-cli/reference/search.md | 2 +- skills/bailian-cli/reference/speech.md | 2 +- skills/bailian-cli/reference/text.md | 26 +- skills/bailian-cli/reference/video.md | 18 +- 69 files changed, 845 insertions(+), 1257 deletions(-) delete mode 100644 packages/cli/tests/args.test.ts delete mode 100644 packages/cli/tests/index.test.ts delete mode 100644 packages/cli/tests/proxy.test.ts create mode 100644 packages/runtime/src/middleware.ts create mode 100644 packages/runtime/src/resolve.ts delete mode 100644 packages/runtime/src/utils/command-help.ts diff --git a/packages/cli/tests/args.test.ts b/packages/cli/tests/args.test.ts deleted file mode 100644 index b32d208..0000000 --- a/packages/cli/tests/args.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import { ExitCode, GLOBAL_OPTIONS } from "bailian-cli-core"; -import { parseFlags } from "../src/args.ts"; -import { BOOL_FLAG_WATERMARK } from "../src/utils/flag-descriptions.ts"; - -const IMAGE_GENERATE_OPTIONS = [ - { flag: "--prompt ", description: "Image description", required: true }, - { flag: "--model ", description: "Model ID" }, - { flag: "--watermark ", description: BOOL_FLAG_WATERMARK }, - { flag: "--no-wait", description: "Return task ID immediately without waiting" }, -]; - -test("parseFlags rejects unknown long flags", () => { - expect(() => - parseFlags(["--prompt", "cat", "--xxxx", "a"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]), - ).toThrowError( - expect.objectContaining({ - name: "BailianError", - exitCode: ExitCode.USAGE, - message: expect.stringContaining('Unknown flag "--xxxx"'), - }), - ); -}); - -test("parseFlags rejects unknown flags with = syntax", () => { - expect(() => - parseFlags( - ["--prompt=cat", "--unknown-flag=yes"], - [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS], - ), - ).toThrow(/Unknown flag "--unknown-flag"/); -}); - -test("parseFlags accepts defined command and global flags", () => { - const flags = parseFlags( - ["--quiet", "--prompt", "cat", "--watermark", "false"], - [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS], - ); - expect(flags.quiet).toBe(true); - expect(flags.prompt).toBe("cat"); - expect(flags.watermark).toBe("false"); -}); - -test("parseFlags rejects value flag when next token is another flag", () => { - const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; - for (const argv of [ - ["--watermark", "--prompt", "cat"], - ["--watermark", "-h"], - ["--prompt", "cat", "--watermark", "--model", "qwen-image-2.0"], - ]) { - expect(() => parseFlags(argv, opts)).toThrowError( - expect.objectContaining({ - name: "BailianError", - exitCode: ExitCode.USAGE, - message: expect.stringContaining("Flag --watermark requires a value"), - }), - ); - } -}); - -test("parseFlags rejects trailing value flag without value", () => { - expect(() => - parseFlags(["--prompt", "cat", "--watermark"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]), - ).toThrowError( - expect.objectContaining({ - message: expect.stringContaining("Flag --watermark requires a value"), - }), - ); -}); - -test("parseFlags allows boolean flags without values adjacent to other flags", () => { - const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; - const flags = parseFlags( - ["--quiet", "--dry-run", "--no-wait", "--prompt", "cat", "--watermark", "false"], - opts, - ); - expect(flags.quiet).toBe(true); - expect(flags.dryRun).toBe(true); - expect(flags.noWait).toBe(true); - expect(flags.prompt).toBe("cat"); - expect(flags.watermark).toBe("false"); -}); - -test("parseFlags does not treat the next flag as a boolean flag value", () => { - const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; - expect(() => parseFlags(["--dry-run", "--prompt"], opts)).toThrowError( - expect.objectContaining({ - message: expect.stringContaining("Flag --prompt requires a value"), - }), - ); - // --dry-run is boolean: no value check; parsing continues to --prompt. - const flags = parseFlags(["--dry-run", "--prompt", "cat"], opts); - expect(flags.dryRun).toBe(true); - expect(flags.prompt).toBe("cat"); -}); diff --git a/packages/cli/tests/e2e/config.e2e.test.ts b/packages/cli/tests/e2e/config.e2e.test.ts index 5527e32..fe85a00 100644 --- a/packages/cli/tests/e2e/config.e2e.test.ts +++ b/packages/cli/tests/e2e/config.e2e.test.ts @@ -63,10 +63,10 @@ describe("e2e: config", () => { expect(stdout).toMatch(/config_file|timeout|base_url/i); }); - test("config set 缺少 --key / --value 时退出为用法错误 (2)", async () => { + test("config set 缺少 --key / --value 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli(["config", "set", "--non-interactive"]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/--key|--value|required/i); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--key|--value|Usage:/i); }); test("config set 非法 key 时退出为用法错误", async () => { diff --git a/packages/cli/tests/e2e/knowledge.e2e.test.ts b/packages/cli/tests/e2e/knowledge.e2e.test.ts index 3290017..deb6c48 100644 --- a/packages/cli/tests/e2e/knowledge.e2e.test.ts +++ b/packages/cli/tests/e2e/knowledge.e2e.test.ts @@ -66,7 +66,7 @@ describe("e2e: knowledge retrieve", () => { // ---- Error scenarios (no real credentials needed) ---- describe("e2e: knowledge retrieve errors", () => { - test("无任何凭证时提示 No credentials found 并非零退出", async () => { + test("无任何凭证时提示缺少密钥并非零退出", async () => { const { stderr, exitCode } = await runCli( [ "knowledge", @@ -88,7 +88,7 @@ describe("e2e: knowledge retrieve errors", () => { }, ); expect(exitCode).not.toBe(0); - expect(stderr).toMatch(/no credentials found/i); + expect(stderr).toMatch(/no api key found|no credentials found/i); }); }); diff --git a/packages/cli/tests/e2e/mcp.e2e.test.ts b/packages/cli/tests/e2e/mcp.e2e.test.ts index 4018acf..ba45aaa 100644 --- a/packages/cli/tests/e2e/mcp.e2e.test.ts +++ b/packages/cli/tests/e2e/mcp.e2e.test.ts @@ -33,13 +33,13 @@ describe("e2e: mcp", () => { test("mcp tools --help 正常退出", async () => { const { stderr, exitCode } = await runCli(["mcp", "tools", "--help"]); expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/tools|server-code|--url/i); + expect(stderr).toMatch(/tools|--server|--url/i); }); test("mcp call --help 正常退出", async () => { const { stderr, exitCode } = await runCli(["mcp", "call", "--help"]); expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/call|server-code|tool|--arg|--json/i); + expect(stderr).toMatch(/call|--target|--arg|--json/i); }); test("mcp list --help 不暴露 --all 入口(市场全量已下线)", async () => { @@ -103,10 +103,11 @@ describe("e2e: mcp", () => { expect(data.consoleRegion).toBe("cn-hangzhou"); }); - test("mcp tools --dry-run 输出 /api/v1/mcps//mcp 形态 URL", async () => { + test("mcp tools --server --dry-run 输出 /api/v1/mcps//mcp 形态 URL", async () => { const { stdout, stderr, exitCode } = await runCli([ "mcp", "tools", + "--server", "market-cmapi00073529", "--dry-run", "--non-interactive", @@ -126,6 +127,7 @@ describe("e2e: mcp", () => { const { stdout, stderr, exitCode } = await runCli([ "mcp", "tools", + "--server", "my-server", "--url", "https://example.com/custom/mcp", @@ -140,16 +142,17 @@ describe("e2e: mcp", () => { expect(data.url).toBe("https://example.com/custom/mcp"); }); - test("mcp tools 缺少 server-code 时打印子命令帮助并退出 (0)", async () => { + test("mcp tools 缺少 --server 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli(["mcp", "tools", "--non-interactive"]); expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/server-code|Usage:/i); + expect(stderr).toMatch(/--server|Usage:/i); }); - test("mcp call . --dry-run 输出工具调用计划", async () => { + test("mcp call --target --dry-run 输出工具调用计划", async () => { const { stdout, stderr, exitCode } = await runCli([ "mcp", "call", + "--target", "market-cmapi00073529.SmartStockSelection", "--query", "筛选ROE>15%的消费股", @@ -176,6 +179,7 @@ describe("e2e: mcp", () => { const { stdout, stderr, exitCode } = await runCli([ "mcp", "call", + "--target", "market-cmapi00073529.FinQuery", "--json", '{"q":"贵州茅台","limit":5,"riskLevel":"R2"}', @@ -208,10 +212,11 @@ describe("e2e: mcp", () => { expect(data.arguments?.query).toBe("招商银行"); }); - test("mcp call 目标缺少 . 时报错且非零退出", async () => { + test("mcp call --target 缺少 . 时报错且非零退出", async () => { const { stderr, exitCode } = await runCli([ "mcp", "call", + "--target", "no-dot-target", "--non-interactive", "--output", @@ -225,6 +230,7 @@ describe("e2e: mcp", () => { const { stderr, exitCode } = await runCli([ "mcp", "call", + "--target", "srv.tool", "--arg", "no-equals-sign", @@ -240,6 +246,7 @@ describe("e2e: mcp", () => { const { stderr, exitCode } = await runCli([ "mcp", "call", + "--target", "srv.tool", "--json", "{not-json", @@ -251,10 +258,10 @@ describe("e2e: mcp", () => { expect(stderr).toMatch(/--json is not valid JSON|--json must decode/); }); - test("mcp call 缺少 positional 时打印子命令帮助并退出 (0)", async () => { + test("mcp call 缺少 --target 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli(["mcp", "call", "--non-interactive"]); expect(exitCode, stderr).toBe(0); - expect(stderr).toMatch(/server-code|Usage:/i); + expect(stderr).toMatch(/--target|Usage:/i); }); }); @@ -266,6 +273,7 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: mcp (live)", () => { const { stdout, stderr, exitCode } = await runCli([ "mcp", "tools", + "--server", "WebSearch", "--non-interactive", "--output", diff --git a/packages/cli/tests/e2e/pipeline.e2e.test.ts b/packages/cli/tests/e2e/pipeline.e2e.test.ts index d2141d5..9d562e1 100644 --- a/packages/cli/tests/e2e/pipeline.e2e.test.ts +++ b/packages/cli/tests/e2e/pipeline.e2e.test.ts @@ -89,6 +89,7 @@ describe("e2e: pipeline", () => { const { stdout, stderr, exitCode } = await runCli([ "pipeline", "validate", + "--file", chatBasicPath, "--output", "json", @@ -100,9 +101,12 @@ describe("e2e: pipeline", () => { }); test("pipeline validate 使用 config 输出格式", async () => { - const { stdout, stderr, exitCode } = await runCli(["pipeline", "validate", chatBasicPath], { - DASHSCOPE_OUTPUT: "text", - }); + const { stdout, stderr, exitCode } = await runCli( + ["pipeline", "validate", "--file", chatBasicPath], + { + DASHSCOPE_OUTPUT: "text", + }, + ); expect(exitCode, stderr).toBe(0); expect(stdout).toBe("Pipeline definition is valid.\n"); }); @@ -111,6 +115,7 @@ describe("e2e: pipeline", () => { const { stdout, stderr, exitCode } = await runCli([ "pipeline", "validate", + "--file", invalidPipelinePath, "--output", "json", @@ -122,16 +127,17 @@ describe("e2e: pipeline", () => { expect(data.issues?.join("\n")).toMatch(/pipeline graph contains cycle/i); }); - test("pipeline run 缺少 file 时退出为用法错误 (2)", async () => { + test("pipeline run 缺少 --file 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli(["pipeline", "run", "--non-interactive"]); - expect(exitCode).toBe(2); - expect(stderr).toMatch(/pipeline file is required|Usage: bl pipeline run /i); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/Usage: bl pipeline run --file |--file/i); }); test("pipeline run --dry-run --output json 仅输出计划", async () => { const { stdout, stderr, exitCode } = await runCli([ "pipeline", "run", + "--file", chatBasicPath, "--input", '{"message":"hello"}', @@ -166,6 +172,7 @@ describe("e2e: pipeline", () => { [ "pipeline", "run", + "--file", chatBasicPath, "--input", '{"message":"hello"}', @@ -183,6 +190,7 @@ describe("e2e: pipeline", () => { const { stderr, exitCode } = await runCli([ "pipeline", "run", + "--file", chatBasicPath, "--input", '{"message":"hello"}', @@ -199,6 +207,7 @@ describe("e2e: pipeline", () => { const { stdout, stderr, exitCode } = await runCli([ "pipeline", "run", + "--file", chatBasicPath, "--input", '{"message":"hello"}', @@ -227,6 +236,7 @@ describe("e2e: pipeline", () => { const { stdout, stderr, exitCode } = await runCli([ "pipeline", "run", + "--file", chatBasicPath, "--dry-run", "--events", diff --git a/packages/cli/tests/e2e/search-web.e2e.test.ts b/packages/cli/tests/e2e/search-web.e2e.test.ts index 9b0c353..fbb9b35 100644 --- a/packages/cli/tests/e2e/search-web.e2e.test.ts +++ b/packages/cli/tests/e2e/search-web.e2e.test.ts @@ -27,6 +27,19 @@ describe("e2e: search web", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/web|--query|list-tools|count/i); }); + + test("search web --dry-run --list-tools 无需 --query 也无需凭证即可干跑", async () => { + const { stdout, stderr, exitCode } = await runCli( + ["search", "web", "--dry-run", "--list-tools", "--non-interactive", "--output", "json"], + { + DASHSCOPE_API_KEY: undefined, + DASHSCOPE_ACCESS_TOKEN: undefined, + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ action?: string }>(stdout); + expect(data.action).toBe("tools/list"); + }); }); describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => { @@ -61,21 +74,6 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: search web", () => { expect(data.arguments?.count).toBe(5); }); - test("search web --dry-run --list-tools 仅描述 tools/list", async () => { - const { stdout, stderr, exitCode } = await runCli([ - "search", - "web", - "--dry-run", - "--list-tools", - "--non-interactive", - "--output", - "json", - ]); - expect(exitCode, stderr).toBe(0); - const data = parseStdoutJson<{ action?: string }>(stdout); - expect(data.action).toBe("tools/list"); - }); - test("联网搜索返回 JSON 且含搜索结果", async () => { const { stdout, stderr, exitCode } = await runCli([ "search", diff --git a/packages/cli/tests/e2e/video-download.e2e.test.ts b/packages/cli/tests/e2e/video-download.e2e.test.ts index 8e4dcf8..5f819c2 100644 --- a/packages/cli/tests/e2e/video-download.e2e.test.ts +++ b/packages/cli/tests/e2e/video-download.e2e.test.ts @@ -87,9 +87,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const genMp4 = join(outDir, "e2e-gen-for-download.mp4"); const gen = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-t2v", "--duration", @@ -116,9 +116,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const downloadMp4 = join(outDir, "e2e-download.mp4"); const dl = await runCli([ - ...cliTimeoutPrefix(), "video", "download", + ...cliTimeoutPrefix(), "--task-id", genData.task_id!, "--out", diff --git a/packages/cli/tests/e2e/video-edit.e2e.test.ts b/packages/cli/tests/e2e/video-edit.e2e.test.ts index 44fb32a..a8e2543 100644 --- a/packages/cli/tests/e2e/video-edit.e2e.test.ts +++ b/packages/cli/tests/e2e/video-edit.e2e.test.ts @@ -33,9 +33,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( () => { test("video edit 缺少 --video 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "edit", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-video-edit", "--prompt", @@ -51,9 +51,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const t2vPath = join(outDir, "e2e-video-t2v.mp4"); const t2v = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-t2v", "--prompt", @@ -69,9 +69,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( expect(t2vData.status).toBe("SUCCEEDED"); const { stdout, stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "edit", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-video-edit", "--video", diff --git a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts b/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts index df61a63..4b4adf2 100644 --- a/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts +++ b/packages/cli/tests/e2e/video-generate-i2v.e2e.test.ts @@ -33,9 +33,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( () => { test("video generate 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-i2v", "--image", @@ -48,9 +48,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("video generate --dry-run(无 --image)仅输出 request(t2v 路径不调上传)", async () => { const { stdout, stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--dry-run", "--model", "happyhorse-1.0-t2v", @@ -91,9 +91,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( const imagePath = genData.saved?.[0] ?? png; const { stdout, stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-i2v", "--image", diff --git a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts b/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts index 56af3e1..ab70364 100644 --- a/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts +++ b/packages/cli/tests/e2e/video-generate-t2v.e2e.test.ts @@ -33,9 +33,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( () => { test("video generate 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-t2v", "--non-interactive", @@ -46,11 +46,11 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("video generate --dry-run(无 --image)仅输出 request 且不调生成接口", async () => { const { stdout, stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", "--dry-run", "--model", + ...cliTimeoutPrefix(), "happyhorse-1.0-t2v", "--prompt", "干跑校验", @@ -69,9 +69,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("【happyhorse-1.0-t2v】文本生成视频", async () => { const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); const { stdout, stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "generate", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-t2v", "--prompt", diff --git a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts b/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts index 51d4d48..dd15161 100644 --- a/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts +++ b/packages/cli/tests/e2e/video-ref-r2v.e2e.test.ts @@ -33,9 +33,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( () => { test("video ref 缺少 --prompt 时打印子命令帮助并退出 (0)", async () => { const { stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "ref", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-r2v", "--image", @@ -48,9 +48,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( test("video ref 缺少 --image 与 --ref-video 时退出为用法错误 (2)", async () => { const { stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "ref", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-r2v", "--prompt", @@ -84,9 +84,9 @@ describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())( expect(imagePath).toBeTruthy(); const { stdout, stderr, exitCode } = await runCli([ - ...cliTimeoutPrefix(), "video", "ref", + ...cliTimeoutPrefix(), "--model", "happyhorse-1.0-r2v", "--prompt", diff --git a/packages/cli/tests/index.test.ts b/packages/cli/tests/index.test.ts deleted file mode 100644 index f6853c0..0000000 --- a/packages/cli/tests/index.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import { createStepDispatcher } from "../src/pipeline/dispatcher.ts"; -import { executePipeline } from "../src/pipeline/executor.ts"; -import { collectPipelineIssues } from "../src/pipeline/validation.ts"; -import { getByJsonPointer } from "../src/pipeline/schema.ts"; -import { normalizeConcurrency } from "../src/pipeline/scheduler.ts"; -import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts"; - -test("cli package skeleton", () => { - expect(true).toBe(true); -}); - -test("pipeline execution can use an isolated step dispatcher", async () => { - const dispatcher = createStepDispatcher(); - dispatcher.registerStep("test/echo", (input, ctx) => ({ - data: { input, hasSignal: !!ctx.signal }, - })); - - const controller = new AbortController(); - const pipeline: PipelineDefinition = { - version: WORKFLOW_VERSION, - steps: [{ id: "echo", type: "test/echo", input: { message: "hello" } }], - }; - - const report = await executePipeline( - pipeline, - {}, - { - stepDispatcher: dispatcher, - signal: controller.signal, - }, - ); - - expect(report.status).toBe("succeeded"); - expect(report.steps[0]?.output?.data).toEqual({ - input: { message: "hello" }, - hasSignal: true, - }); -}); - -test("dry-run never executes $js expressions (preview must not run code)", async () => { - const dispatcher = createStepDispatcher(); - dispatcher.registerStep("test/echo", (input) => ({ data: input })); - const flag = "__bailian_dryrun_should_not_run__"; - delete (globalThis as Record)[flag]; - - const pipeline: PipelineDefinition = { - version: WORKFLOW_VERSION, - steps: [ - { - id: "s1", - type: "test/echo", - input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } }, - }, - ], - }; - - const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true }); - expect(report.status).toBe("planned"); - expect((globalThis as Record)[flag]).toBeUndefined(); -}); - -test("script/js rejects non-literal code sourced from another step ($from)", () => { - const dispatcher = createStepDispatcher(); - dispatcher.registerStep("test/echo", (input) => ({ data: input })); - dispatcher.registerStep("script/js", () => ({ data: {} })); - - const pipeline: PipelineDefinition = { - version: WORKFLOW_VERSION, - steps: [ - { id: "gen", type: "test/echo", input: { message: "x" } }, - { - id: "run", - type: "script/js", - input: { code: { $from: "gen", path: "/data/message" } as never }, - }, - ], - }; - - const issues = collectPipelineIssues(pipeline, dispatcher); - expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true); -}); - -test("script/js accepts a literal string code", () => { - const dispatcher = createStepDispatcher(); - dispatcher.registerStep("script/js", () => ({ data: {} })); - - const pipeline: PipelineDefinition = { - version: WORKFLOW_VERSION, - steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }], - }; - - expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]); -}); - -test("getByJsonPointer refuses prototype keys and inherited properties", () => { - const obj = { a: { b: 1 } }; - expect(getByJsonPointer(obj, "/a/b")).toBe(1); - expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined(); - expect(getByJsonPointer(obj, "/constructor")).toBeUndefined(); - expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined(); - expect(getByJsonPointer(obj, "/toString")).toBeUndefined(); -}); - -test("normalizeConcurrency clamps to a safe maximum", () => { - expect(normalizeConcurrency(undefined)).toBe(1); - expect(normalizeConcurrency(4)).toBe(4); - expect(normalizeConcurrency(100000)).toBe(64); -}); diff --git a/packages/cli/tests/proxy.test.ts b/packages/cli/tests/proxy.test.ts deleted file mode 100644 index 1986459..0000000 --- a/packages/cli/tests/proxy.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, test } from "vite-plus/test"; -import { readProxyEnv } from "../src/proxy.ts"; - -test("readProxyEnv: 未设置任何代理变量时全部为 undefined", () => { - expect(readProxyEnv({})).toEqual({ - httpProxy: undefined, - httpsProxy: undefined, - noProxy: undefined, - }); -}); - -test("readProxyEnv: 空白值视为未设置", () => { - expect(readProxyEnv({ HTTPS_PROXY: "", HTTP_PROXY: " ", NO_PROXY: "" })).toEqual({ - httpProxy: undefined, - httpsProxy: undefined, - noProxy: undefined, - }); -}); - -test("readProxyEnv: 大小写变量均可识别,小写优先", () => { - expect(readProxyEnv({ HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe("http://upper:1"); - expect(readProxyEnv({ https_proxy: "http://lower:1" }).httpsProxy).toBe("http://lower:1"); - expect( - readProxyEnv({ https_proxy: "http://lower:1", HTTPS_PROXY: "http://upper:1" }).httpsProxy, - ).toBe("http://lower:1"); -}); - -test("readProxyEnv: 空字符串小写变量不屏蔽已设置的大写变量", () => { - expect(readProxyEnv({ https_proxy: "", HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe( - "http://upper:1", - ); - expect(readProxyEnv({ http_proxy: "", HTTP_PROXY: "http://upper:2" }).httpProxy).toBe( - "http://upper:2", - ); -}); - -test("readProxyEnv: NO_PROXY 独立读取", () => { - const r = readProxyEnv({ NO_PROXY: "*.aliyuncs.com" }); - expect(r.noProxy).toBe("*.aliyuncs.com"); - expect(r.httpProxy).toBeUndefined(); - expect(r.httpsProxy).toBeUndefined(); -}); diff --git a/packages/commands/src/commands/advisor/recommend.ts b/packages/commands/src/commands/advisor/recommend.ts index aa707ea..7f76b67 100644 --- a/packages/commands/src/commands/advisor/recommend.ts +++ b/packages/commands/src/commands/advisor/recommend.ts @@ -8,7 +8,6 @@ import { type GlobalFlags, getModels, type IntentProfile, - isInteractive, type PipelineStep, type RecommendedModel, type RecommendResult, @@ -19,7 +18,6 @@ import boxen from "boxen"; import chalk, { Chalk, type ChalkInstance } from "chalk"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { createSpinner } from "bailian-cli-runtime"; -import { failIfMissing, promptText, cmdUsage } from "bailian-cli-runtime"; function formatContextWindow(tokens: number): string { if (tokens >= 1_000_000) @@ -218,19 +216,12 @@ export default defineCommand({ description: "Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking)", auth: "apiKey", - usageArgs: " [flags]", + usageArgs: "--message [flags]", options: [ { flag: "--message ", - description: "Describe your requirements (alternative to positional prompt)", - }, - { - flag: "--dry-run", - description: "Show intent analysis and candidate list without LLM ranking", - }, - { - flag: "--output ", - description: "Output format: text (default in TTY), json, yaml", + description: "Describe your requirements", + required: true, }, ], exampleArgs: [ @@ -239,24 +230,9 @@ export default defineCommand({ '--message "Legal contract review, high precision required"', '--message "Low-cost high-concurrency online customer service" --output json', '--message "Long document summarization" --dry-run', - " # Interactive input", ], async run(config: Config, flags: GlobalFlags) { - const positional = ((flags as Record)._positional as string[]) ?? []; - let userInput = (flags.message as string) || positional.join(" "); - - if (!userInput.trim()) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Describe your requirement:" }); - if (!hint) { - process.stderr.write("Cancelled.\n"); - process.exit(1); - } - userInput = hint; - } else { - failIfMissing("message", cmdUsage(config, '"your requirement"')); - } - } + const userInput = flags.message as string; const top = 3; const format = detectOutputFormat(config.output); diff --git a/packages/commands/src/commands/app/call.ts b/packages/commands/src/commands/app/call.ts index a2d624a..8e050ef 100644 --- a/packages/commands/src/commands/app/call.ts +++ b/packages/commands/src/commands/app/call.ts @@ -11,7 +11,6 @@ import { type AppStreamChunk, type AppCompletionResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -44,10 +43,7 @@ export default defineCommand({ ], async run(config: Config, flags: GlobalFlags) { const appId = flags.appId as string; - if (!appId) failIfMissing("app-id", cmdUsage(config, "--app-id --prompt ")); - const prompt = flags.prompt as string; - if (!prompt) failIfMissing("prompt", cmdUsage(config, "--app-id --prompt ")); const shouldStream = flags.stream === true || (flags.stream === undefined && process.stdout.isTTY); diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 7f8f78e..ca6752c 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,16 +1,13 @@ import { defineCommand, - isInteractive, - maskToken, readConfigFile, writeConfigFile, type Config, type GlobalFlags, } from "bailian-cli-core"; +import { IncompleteCommandError } from "bailian-cli-core"; import { printQuickStart } from "bailian-cli-runtime"; import { emitBare } from "bailian-cli-runtime"; -import { promptConfirm } from "bailian-cli-runtime"; -import { printCurrentCommandHelp } from "bailian-cli-runtime"; import { resolveConsoleOrigin, runConsoleLogin, @@ -51,25 +48,12 @@ export default defineCommand({ const envKey = process.env.DASHSCOPE_API_KEY; if (envKey && !flags.apiKey) { - const maskedEnvKey = maskToken(envKey); - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const proceed = await promptConfirm({ - message: `Detected DASHSCOPE_API_KEY in environment (${maskedEnvKey}).\nYou are already authenticated via env.\nDo you still want to configure local persistent credentials?`, - initialValue: false, - }); - if (!proceed) { - process.stdout.write("Login skipped. Using environment variables.\n"); - process.exit(0); - } - } else { - process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`); - } + process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`); } const key = (flags.apiKey as string) || config.apiKey; if (!key) { - printCurrentCommandHelp(process.stderr); - process.exit(0); + throw new IncompleteCommandError("Missing required argument: --api-key"); } const baseUrl = (flags.baseUrl as string) || undefined; diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 9ec9427..50fccad 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -9,7 +9,7 @@ import { type GlobalFlags, ExitCode, } from "bailian-cli-core"; -import { emitResult, cmdUsage } from "bailian-cli-runtime"; +import { emitResult } from "bailian-cli-runtime"; const VALID_KEYS = [ "base_url", @@ -58,8 +58,9 @@ export default defineCommand({ flag: "--key ", description: "Config key (base_url, output, output_dir, timeout, api_key, access_token, default_*_model, access_key_id, access_key_secret, workspace_id)", + required: true, }, - { flag: "--value ", description: "Value to set" }, + { flag: "--value ", description: "Value to set", required: true }, ], exampleArgs: [ "--key output --value json", @@ -67,16 +68,8 @@ export default defineCommand({ "--key base_url --value https://dashscope.aliyuncs.com", ], async run(config: Config, flags: GlobalFlags) { - const key = flags.key as string | undefined; - const value = flags.value as string | undefined; - - if (!key || value === undefined) { - throw new BailianError( - "--key and --value are required.", - ExitCode.USAGE, - cmdUsage(config, "--key --value "), - ); - } + const key = flags.key as string; + const value = flags.value as string; // Resolve hyphen aliases to underscore keys const resolvedKey: string = KEY_ALIASES[key] || key; diff --git a/packages/commands/src/commands/console/call.ts b/packages/commands/src/commands/console/call.ts index 084653c..7728850 100644 --- a/packages/commands/src/commands/console/call.ts +++ b/packages/commands/src/commands/console/call.ts @@ -9,7 +9,6 @@ import { type Config, type GlobalFlags, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime"; export default defineCommand({ @@ -44,10 +43,7 @@ export default defineCommand({ ], async run(config: Config, flags: GlobalFlags) { const api = flags.api as string; - if (!api) failIfMissing("api", cmdUsage(config, "--api --data ")); - const dataRaw = flags.data as string; - if (!dataRaw) failIfMissing("data", cmdUsage(config, "--api --data ")); let data: Record; try { diff --git a/packages/commands/src/commands/file/upload.ts b/packages/commands/src/commands/file/upload.ts index 7713222..41756f6 100644 --- a/packages/commands/src/commands/file/upload.ts +++ b/packages/commands/src/commands/file/upload.ts @@ -6,7 +6,6 @@ import { type GlobalFlags, uploadFile, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -32,15 +31,8 @@ export default defineCommand({ "--file cat.png --model qwen-image-2.0", ], async run(config: Config, flags: GlobalFlags) { - const filePath = flags.file as string | undefined; - if (!filePath) { - failIfMissing("file", cmdUsage(config, "--file --model ")); - } - - const model = flags.model as string | undefined; - if (!model) { - failIfMissing("model", cmdUsage(config, "--file --model ")); - } + const filePath = flags.file as string; + const model = flags.model as string; const format = detectOutputFormat(config.output); @@ -54,8 +46,8 @@ export default defineCommand({ const ossUrl = await uploadFile({ apiKey: credential.token, - model: model!, - filePath: filePath!, + model, + filePath, }); if (config.quiet) { diff --git a/packages/commands/src/commands/image/edit.ts b/packages/commands/src/commands/image/edit.ts index 69e10ff..fd89e22 100644 --- a/packages/commands/src/commands/image/edit.ts +++ b/packages/commands/src/commands/image/edit.ts @@ -9,7 +9,6 @@ import { resolveFileUrl, resolveOutputDir, generateFilename, - isInteractive, stripUndefined, type DashScopeImageRequest, type DashScopeImageSyncResponse, @@ -20,7 +19,6 @@ import { } from "bailian-cli-core"; import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveImageSize } from "bailian-cli-runtime"; import { join } from "path"; @@ -52,10 +50,12 @@ export default defineCommand({ { flag: "--prompt-extend ", description: BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, + type: "boolean", }, { flag: "--watermark ", description: BOOL_FLAG_WATERMARK, + type: "boolean", }, { flag: "--out-dir ", description: "Download images to directory" }, { flag: "--out-prefix ", description: "Filename prefix (default: edited)" }, @@ -75,25 +75,7 @@ export default defineCommand({ } else if (typeof flags.image === "string") { rawImages = [flags.image]; } - if (rawImages.length === 0) { - failIfMissing("image", cmdUsage(config, "--image --prompt ")); - } - - let prompt = flags.prompt as string | undefined; - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ - message: "Enter your edit instruction:", - }); - if (!hint) { - process.stderr.write("Image editing cancelled.\n"); - process.exit(1); - } - prompt = hint; - } else { - failIfMissing("prompt", cmdUsage(config, "--image --prompt ")); - } - } + const prompt = flags.prompt as string; const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0"; diff --git a/packages/commands/src/commands/image/generate.ts b/packages/commands/src/commands/image/generate.ts index 6eecc2e..759a2fc 100644 --- a/packages/commands/src/commands/image/generate.ts +++ b/packages/commands/src/commands/image/generate.ts @@ -8,7 +8,6 @@ import { type Config, type GlobalFlags, resolveOutputDir, - isInteractive, type DashScopeImageRequest, type DashScopeImageSyncResponse, BailianError, @@ -23,7 +22,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { resolveImageSize } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; @@ -61,10 +59,12 @@ export default defineCommand({ { flag: "--prompt-extend ", description: BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, + type: "boolean", }, { flag: "--watermark ", description: BOOL_FLAG_WATERMARK, + type: "boolean", }, { flag: "--no-wait", @@ -90,24 +90,7 @@ export default defineCommand({ '--prompt "Product shots" --n 2 --concurrent 3 # 6 images in parallel', ], async run(config: Config, flags: GlobalFlags) { - let prompt = (flags.prompt ?? (flags._positional as string[] | undefined)?.[0]) as - | string - | undefined; - - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ - message: "Enter your image prompt:", - }); - if (!hint) { - process.stderr.write("Image generation cancelled.\n"); - process.exit(1); - } - prompt = hint; - } else { - failIfMissing("prompt", cmdUsage(config, "--prompt ")); - } - } + const prompt = flags.prompt as string; const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0"; const useSync = isSyncModel(model); @@ -281,8 +264,7 @@ async function saveImages( subDir: flags.outDir ? undefined : "images", }); - const promptText = - (flags.prompt as string) || (flags._positional as string[] | undefined)?.[0] || ""; + const promptText = (flags.prompt as string) || ""; const prefix = (flags.outPrefix as string) || generateFilename("image", promptText); // Parallel download all images diff --git a/packages/commands/src/commands/knowledge/retrieve.ts b/packages/commands/src/commands/knowledge/retrieve.ts index 820fff6..355681f 100644 --- a/packages/commands/src/commands/knowledge/retrieve.ts +++ b/packages/commands/src/commands/knowledge/retrieve.ts @@ -17,7 +17,6 @@ import { BailianError, ExitCode, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; const BAILIAN_HOST = "bailian.cn-beijing.aliyuncs.com"; @@ -81,10 +80,7 @@ export default defineCommand({ ], async run(config: Config, flags: GlobalFlags) { const indexId = flags.indexId as string; - if (!indexId) failIfMissing("index-id", cmdUsage(config, "--index-id --query ")); - const query = flags.query as string; - if (!query) failIfMissing("query", cmdUsage(config, "--index-id --query ")); const format = detectOutputFormat(config.output); diff --git a/packages/commands/src/commands/mcp/call.ts b/packages/commands/src/commands/mcp/call.ts index 2bfe262..692bb6e 100644 --- a/packages/commands/src/commands/mcp/call.ts +++ b/packages/commands/src/commands/mcp/call.ts @@ -6,7 +6,6 @@ import { type Config, type GlobalFlags, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime"; import { ensureApiKey } from "bailian-cli-runtime"; @@ -32,10 +31,10 @@ function parseArgFlags(raw: string[]): Record { export default defineCommand({ description: "Call a tool on an MCP server (tools/call)", auth: "apiKey", - usageArgs: ". [--arg k=v ...] [--json '{...}'] [--url ]", + usageArgs: "--target [--arg k=v ...] [--json '{...}'] [--url ]", options: [ { - flag: ".", + flag: "--target ", description: "Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection", required: true, @@ -56,23 +55,20 @@ export default defineCommand({ { flag: "--url ", description: "Override the MCP endpoint URL (for non-Bailian servers)" }, ], exampleArgs: [ - 'market-cmapi00073529.SmartStockSelection --query "Screen consumer stocks with ROE > 15%"', - 'market-cmapi00073529.FinQuery --json \'{"q":"Guizhou Maotai","limit":5}\'', - "market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10", + '--target market-cmapi00073529.SmartStockSelection --query "Screen consumer stocks with ROE > 15%"', + '--target market-cmapi00073529.FinQuery --json \'{"q":"Guizhou Maotai","limit":5}\'', + "--target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10", ], async run(config: Config, flags: GlobalFlags) { - const positional = - ((flags as Record)._positional as string[] | undefined) ?? []; - const target = positional[0]; - if (!target) failIfMissing(".", cmdUsage(config, ".")); + const target = flags.target as string; - const dot = target!.indexOf("."); - if (dot <= 0 || dot === target!.length - 1) { + const dot = target.indexOf("."); + if (dot <= 0 || dot === target.length - 1) { process.stderr.write(`Error: target must be ., got "${target}".\n`); process.exit(1); } - const serverCode = target!.slice(0, dot); - const toolName = target!.slice(dot + 1); + const serverCode = target.slice(0, dot); + const toolName = target.slice(dot + 1); let toolArgs: Record = {}; if (flags.json) { diff --git a/packages/commands/src/commands/mcp/tools.ts b/packages/commands/src/commands/mcp/tools.ts index 23e5744..ac7f5b4 100644 --- a/packages/commands/src/commands/mcp/tools.ts +++ b/packages/commands/src/commands/mcp/tools.ts @@ -6,34 +6,30 @@ import { type Config, type GlobalFlags, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime"; import { ensureApiKey } from "bailian-cli-runtime"; export default defineCommand({ description: "List tools exposed by an MCP server (tools/list)", auth: "apiKey", - usageArgs: " [--url ]", + usageArgs: "--server [--url ]", options: [ { - flag: "", + flag: "--server ", description: "Server code from `mcp list` (e.g. market-cmapi00073529)", required: true, }, { flag: "--url ", description: "Override the MCP endpoint URL (for non-Bailian servers)" }, ], exampleArgs: [ - "market-cmapi00073529", - "market-cmapi00073529 --output json", - "my-server --url https://example.com/mcp", + "--server market-cmapi00073529", + "--server market-cmapi00073529 --output json", + "--server my-server --url https://example.com/mcp", ], async run(config: Config, flags: GlobalFlags) { - const positional = - ((flags as Record)._positional as string[] | undefined) ?? []; - const code = positional[0]; - if (!code) failIfMissing("server-code", cmdUsage(config, "")); + const code = flags.server as string; - const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, code!); + const url = (flags.url as string) || bailianMcpUrl(config.baseUrl, code); const format = detectOutputFormat(config.output); if (config.dryRun) { diff --git a/packages/commands/src/commands/memory/add.ts b/packages/commands/src/commands/memory/add.ts index b238f09..7794231 100644 --- a/packages/commands/src/commands/memory/add.ts +++ b/packages/commands/src/commands/memory/add.ts @@ -8,7 +8,6 @@ import { type MemoryAddRequest, type MemoryAddResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -30,9 +29,9 @@ export default defineCommand({ '--user-id user1 --messages \'[{"role":"user","content":"I like traveling"}]\'', '--user-id user1 --content "Lives in Beijing" --profile-schema schema_xxx', ], + validate: (f) => (!f.messages && !f.content ? "Provide --messages or --content." : undefined), async run(config: Config, flags: GlobalFlags) { const userId = flags.userId as string; - if (!userId) failIfMissing("user-id", cmdUsage(config, "--user-id ")); const body: MemoryAddRequest = { user_id: userId }; @@ -49,11 +48,6 @@ export default defineCommand({ body.custom_content = flags.content as string; } - if (!body.messages && !body.custom_content) { - process.stderr.write("Error: at least one of --messages or --content is required\n"); - process.exit(1); - } - if (flags.profileSchema) body.profile_schema = flags.profileSchema as string; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string; diff --git a/packages/commands/src/commands/memory/delete.ts b/packages/commands/src/commands/memory/delete.ts index 83b8852..0641e9c 100644 --- a/packages/commands/src/commands/memory/delete.ts +++ b/packages/commands/src/commands/memory/delete.ts @@ -6,7 +6,6 @@ import { type Config, type GlobalFlags, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -21,10 +20,7 @@ export default defineCommand({ exampleArgs: ["--node-id node_xxx --user-id user1"], async run(config: Config, flags: GlobalFlags) { const nodeId = flags.nodeId as string; - if (!nodeId) failIfMissing("node-id", cmdUsage(config, "--node-id --user-id ")); - const userId = flags.userId as string; - if (!userId) failIfMissing("user-id", cmdUsage(config, "--node-id --user-id ")); const format = detectOutputFormat(config.output); const params = new URLSearchParams({ user_id: userId }); diff --git a/packages/commands/src/commands/memory/list.ts b/packages/commands/src/commands/memory/list.ts index d5e6a5c..d92e88e 100644 --- a/packages/commands/src/commands/memory/list.ts +++ b/packages/commands/src/commands/memory/list.ts @@ -7,7 +7,6 @@ import { type GlobalFlags, type MemoryNodeListResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -23,7 +22,6 @@ export default defineCommand({ exampleArgs: ["--user-id user1", "--user-id user1 --page-size 20 --page 2"], async run(config: Config, flags: GlobalFlags) { const userId = flags.userId as string; - if (!userId) failIfMissing("user-id", cmdUsage(config, "--user-id ")); const format = detectOutputFormat(config.output); const params = new URLSearchParams(); diff --git a/packages/commands/src/commands/memory/profile-create.ts b/packages/commands/src/commands/memory/profile-create.ts index 0e521e9..0389e60 100644 --- a/packages/commands/src/commands/memory/profile-create.ts +++ b/packages/commands/src/commands/memory/profile-create.ts @@ -8,7 +8,6 @@ import { type ProfileSchemaCreateRequest, type ProfileSchemaCreateResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -29,11 +28,7 @@ export default defineCommand({ ], async run(config: Config, flags: GlobalFlags) { const name = flags.name as string; - if (!name) failIfMissing("name", cmdUsage(config, "--name --attributes ")); - const attrStr = flags.attributes as string; - if (!attrStr) - failIfMissing("attributes", cmdUsage(config, "--name --attributes ")); let attributes; try { diff --git a/packages/commands/src/commands/memory/profile-get.ts b/packages/commands/src/commands/memory/profile-get.ts index 92b7f6d..68751bc 100644 --- a/packages/commands/src/commands/memory/profile-get.ts +++ b/packages/commands/src/commands/memory/profile-get.ts @@ -7,7 +7,6 @@ import { type GlobalFlags, type UserProfileResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -21,10 +20,7 @@ export default defineCommand({ exampleArgs: ["--schema-id schema_xxx --user-id user1"], async run(config: Config, flags: GlobalFlags) { const schemaId = flags.schemaId as string; - if (!schemaId) failIfMissing("schema-id", cmdUsage(config, "--schema-id --user-id ")); - const userId = flags.userId as string; - if (!userId) failIfMissing("user-id", cmdUsage(config, "--schema-id --user-id ")); const format = detectOutputFormat(config.output); const params = new URLSearchParams({ user_id: userId }); diff --git a/packages/commands/src/commands/memory/search.ts b/packages/commands/src/commands/memory/search.ts index ff0f070..724fe81 100644 --- a/packages/commands/src/commands/memory/search.ts +++ b/packages/commands/src/commands/memory/search.ts @@ -8,7 +8,6 @@ import { type MemorySearchRequest, type MemorySearchResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -30,9 +29,9 @@ export default defineCommand({ '--user-id user1 --query "programming preferences"', '--user-id user1 --messages \'[{"role":"user","content":"recommend a book"}]\' --top-k 5', ], + validate: (f) => (!f.query && !f.messages ? "Provide --query or --messages." : undefined), async run(config: Config, flags: GlobalFlags) { const userId = flags.userId as string; - if (!userId) failIfMissing("user-id", cmdUsage(config, "--user-id ")); const body: MemorySearchRequest = { user_id: userId }; @@ -52,11 +51,6 @@ export default defineCommand({ body.messages = [{ role: "user", content: body.query }]; } - if (!body.query && !body.messages) { - process.stderr.write("Error: at least one of --query or --messages is required\n"); - process.exit(1); - } - if (flags.topK !== undefined) body.top_k = flags.topK as number; if (flags.memoryLibraryId) body.memory_library_id = flags.memoryLibraryId as string; diff --git a/packages/commands/src/commands/memory/update.ts b/packages/commands/src/commands/memory/update.ts index 4d814ec..472a269 100644 --- a/packages/commands/src/commands/memory/update.ts +++ b/packages/commands/src/commands/memory/update.ts @@ -7,7 +7,6 @@ import { type GlobalFlags, type MemoryNodeUpdateRequest, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -27,16 +26,8 @@ export default defineCommand({ exampleArgs: ['--node-id node_xxx --user-id user1 --content "updated memory content"'], async run(config: Config, flags: GlobalFlags) { const nodeId = flags.nodeId as string; - if (!nodeId) - failIfMissing("node-id", cmdUsage(config, "--node-id --user-id --content ")); - const userId = flags.userId as string; - if (!userId) - failIfMissing("user-id", cmdUsage(config, "--node-id --user-id --content ")); - const content = flags.content as string; - if (!content) - failIfMissing("content", cmdUsage(config, "--node-id --user-id --content ")); const body: MemoryNodeUpdateRequest = { user_id: userId, diff --git a/packages/commands/src/commands/omni/chat.ts b/packages/commands/src/commands/omni/chat.ts index 317fe57..f7861aa 100644 --- a/packages/commands/src/commands/omni/chat.ts +++ b/packages/commands/src/commands/omni/chat.ts @@ -14,10 +14,8 @@ import { type ChatMessageContent, type ChatRequest, type StreamChunk, - isInteractive, resolveFileUrl, } from "bailian-cli-core"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime"; import { resolveOutputDir, resolveCredential } from "bailian-cli-core"; @@ -130,23 +128,7 @@ export default defineCommand({ ], async run(config: Config, flags: GlobalFlags) { // --- Parse messages --- - let userMessages: string[] = []; - if (flags.message) { - userMessages = flags.message as string[]; - } - - if (userMessages.length === 0) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Enter your message:" }); - if (!hint) { - process.stderr.write("Omni chat cancelled.\n"); - process.exit(1); - } - userMessages = [hint]; - } else { - failIfMissing("message", cmdUsage(config, "--message ")); - } - } + const userMessages = flags.message as string[]; const model = (flags.model as string) || config.defaultOmniModel || "qwen3.5-omni-plus"; const voice = (flags.voice as string) || "Cherry"; diff --git a/packages/commands/src/commands/pipeline/run.ts b/packages/commands/src/commands/pipeline/run.ts index 63a6c7a..9b03694 100644 --- a/packages/commands/src/commands/pipeline/run.ts +++ b/packages/commands/src/commands/pipeline/run.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core"; -import { emitResult, cmdUsage } from "bailian-cli-runtime"; +import { emitResult } from "bailian-cli-runtime"; import { initPipelineSteps } from "bailian-cli-runtime"; import { executePipeline, streamPipelineEvents } from "bailian-cli-runtime"; import type { PipelineLifecycleEvent } from "bailian-cli-runtime"; @@ -10,8 +10,9 @@ import { loadPipelineFile } from "./load-file.ts"; export default defineCommand({ description: "Run a pipeline workflow definition", auth: "none", - usageArgs: " [flags]", + usageArgs: "--file [flags]", options: [ + { flag: "--file ", description: "Pipeline definition file (YAML/JSON)", required: true }, { flag: "--input ", description: "Runtime input as inline JSON" }, { flag: "--input-file ", description: "Runtime input from a JSON file" }, { @@ -27,20 +28,14 @@ export default defineCommand({ }, ], exampleArgs: [ - 'workflow.yaml --input \'{"brief":"hello"}\'', - "workflow.json --input-file inputs.json --concurrency 3", - "workflow.yaml --dry-run", - "workflow.json --events jsonl", - "workflow.yaml --output json", + '--file workflow.yaml --input \'{"brief":"hello"}\'', + "--file workflow.json --input-file inputs.json --concurrency 3", + "--file workflow.yaml --dry-run", + "--file workflow.json --events jsonl", + "--file workflow.yaml --output json", ], async run(config: Config, flags: GlobalFlags) { - const file = ((flags._positional as string[] | undefined) ?? [])[0] as string | undefined; - if (!file) { - process.stderr.write( - `Error: pipeline file is required\nUsage: ${cmdUsage(config, "")}\n`, - ); - process.exit(2); - } + const file = flags.file as string; initPipelineSteps(); diff --git a/packages/commands/src/commands/pipeline/validate.ts b/packages/commands/src/commands/pipeline/validate.ts index c5a994b..a47eb23 100644 --- a/packages/commands/src/commands/pipeline/validate.ts +++ b/packages/commands/src/commands/pipeline/validate.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core"; -import { emitResult, cmdUsage } from "bailian-cli-runtime"; +import { emitResult } from "bailian-cli-runtime"; import { initPipelineSteps } from "bailian-cli-runtime"; import { collectPipelineIssues, collectPipelineHints } from "bailian-cli-runtime"; import { loadPipelineFile } from "./load-file.ts"; @@ -8,17 +8,13 @@ import { loadPipelineFile } from "./load-file.ts"; export default defineCommand({ description: "Validate a pipeline definition without executing", auth: "none", - usageArgs: "", - options: [], - exampleArgs: ["workflow.yaml", "workflow.json --output json"], + usageArgs: "--file ", + options: [ + { flag: "--file ", description: "Pipeline definition file (YAML/JSON)", required: true }, + ], + exampleArgs: ["--file workflow.yaml", "--file workflow.json --output json"], async run(config: Config, flags: GlobalFlags) { - const file = ((flags._positional as string[] | undefined) ?? [])[0] as string | undefined; - if (!file) { - process.stderr.write( - `Error: pipeline file is required\nUsage: ${cmdUsage(config, "")}\n`, - ); - process.exit(2); - } + const file = flags.file as string; initPipelineSteps(); diff --git a/packages/commands/src/commands/search/web.ts b/packages/commands/src/commands/search/web.ts index ab527ed..a122d6c 100644 --- a/packages/commands/src/commands/search/web.ts +++ b/packages/commands/src/commands/search/web.ts @@ -4,11 +4,9 @@ import { mcpWebSearchEndpoint, type Config, type GlobalFlags, - isInteractive, McpClient, } from "bailian-cli-core"; import { createSpinner } from "bailian-cli-runtime"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult } from "bailian-cli-runtime"; export default defineCommand({ @@ -16,7 +14,7 @@ export default defineCommand({ auth: "apiKey", usageArgs: "--query [flags]", options: [ - { flag: "--query ", description: "Search query text", required: true }, + { flag: "--query ", description: "Search query text" }, { flag: "--count ", description: "Number of search results (default: 10)", type: "number" }, { flag: "--list-tools", description: "List available MCP tools and exit" }, ], @@ -26,6 +24,7 @@ export default defineCommand({ '--query "Today\'s news"', "--list-tools", ], + validate: (f) => (!f.listTools && !f.query ? "Missing required flag: --query" : undefined), async run(config: Config, flags: GlobalFlags) { const mcpUrl = mcpWebSearchEndpoint(config.baseUrl); const format = detectOutputFormat(config.output); @@ -46,19 +45,7 @@ export default defineCommand({ } // --- Search mode --- - let query = flags.query as string | undefined; - if (!query) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Enter your search query:" }); - if (!hint) { - process.stderr.write("Search cancelled.\n"); - process.exit(1); - } - query = hint; - } else { - failIfMissing("query", cmdUsage(config, "--query ")); - } - } + const query = flags.query as string; if (config.dryRun) { emitResult( diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index 9a573e8..366b326 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -19,7 +19,6 @@ import { speechRecognizeEndpoint, } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -68,9 +67,6 @@ export default defineCommand({ } else if (typeof flags.url === "string") { rawUrls = [flags.url]; } - if (rawUrls.length === 0) { - failIfMissing("url", cmdUsage(config, "--url ")); - } // Strict validation: --speaker-count requires --diarization const speakerCount = flags.speakerCount as number | undefined; diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index 129862c..ae9a07a 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -14,7 +14,7 @@ import { type OutputFormat, speechSynthesizeEndpoint, parseSSE, - isInteractive, + IncompleteCommandError, resolveOutputDir, request, DOCS_HOSTS, @@ -23,7 +23,6 @@ import { const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`; import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; -import { promptText, promptSelect, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; interface VoiceEntry { @@ -146,7 +145,7 @@ export default defineCommand({ auth: "apiKey", usageArgs: "--text [flags]", options: [ - { flag: "--text ", description: "Text to synthesize into speech", required: true }, + { flag: "--text ", description: "Text to synthesize into speech (or use --text-file)" }, { flag: "--text-file ", description: "Read text from a file instead of --text" }, { flag: "--model ", @@ -193,6 +192,12 @@ export default defineCommand({ "# Pipe to ffplay", '--text "Hello" --voice --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 -', ], + validate: (f) => { + if (f.listVoices) return undefined; + if (!f.text && !f.textFile) return "Provide --text or --text-file."; + if (!f.voice) return "Missing required flag: --voice"; + return undefined; + }, async run(config: Config, flags: GlobalFlags) { const model = (flags.model as string) || config.defaultSpeechModel || "cosyvoice-v3-flash"; @@ -215,66 +220,9 @@ export default defineCommand({ } if (!text) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Enter text to synthesize:" }); - if (!hint) { - process.stderr.write("Speech synthesis cancelled.\n"); - process.exit(1); - } - text = hint; - } else { - failIfMissing("text", cmdUsage(config, "--text ")); - } - } - - let voice = (flags.voice as string) || undefined; - - // In interactive mode, prompt the user to select / enter a voice - if (!voice) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const modelVoices = MODEL_VOICES[model]; - if (modelVoices && modelVoices.length > 0) { - const DEFAULT_VOICE = modelVoices[0]!.voice; - const choices = modelVoices.map((v) => ({ - value: v.voice, - label: `${v.name} (${v.voice})`, - hint: `${v.desc} · ${v.lang}`, - })); - const selected = await promptSelect({ - message: `Select a voice (default: ${DEFAULT_VOICE}):`, - choices, - defaultValue: DEFAULT_VOICE, - }); - if (!selected) { - process.stderr.write("Speech synthesis cancelled.\n"); - process.exit(1); - } - voice = selected; - } else { - // No built-in list (v3.5 / v2): prompt for clone/design voice ID - const entered = await promptText({ message: "Enter voice ID (clone/design voice):" }); - if (!entered) { - process.stderr.write("Speech synthesis cancelled.\n"); - process.exit(1); - } - voice = entered; - } - } else { - // Non-interactive mode: keep original error - const modelVoices = MODEL_VOICES[model]; - if (modelVoices && modelVoices.length > 0) { - throw new BailianError( - `--voice is required.\nRun the following to see available voices:\n ${cmdUsage(config, `--list-voices --model ${model}`)}`, - ExitCode.USAGE, - ); - } else { - throw new BailianError( - `--voice is required. Model ${model} has no built-in system voices.\nCreate a clone or design voice first, then pass its ID via --voice .\nSee: ${COSYVOICE_CLONE_DESIGN_DOC}`, - ExitCode.USAGE, - ); - } - } + throw new IncompleteCommandError("Provide --text or --text-file."); } + const voice = flags.voice as string; const language = (flags.language as string) || undefined; const instruction = (flags.instruction as string) || undefined; diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index d2c58c4..e130ff8 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -11,9 +11,7 @@ import { type ChatRequest, type ChatResponse, type StreamChunk, - isInteractive, } from "bailian-cli-core"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { readFileSync } from "fs"; @@ -75,8 +73,7 @@ export default defineCommand({ { flag: "--model ", description: "Model ID (default: qwen3.7-max)" }, { flag: "--message ", - description: "Message text (repeatable, prefix role: to set role)", - required: true, + description: "Message text (repeatable, prefix role: to set role); or use --messages-file", type: "array", }, { @@ -115,24 +112,10 @@ export default defineCommand({ '--message "Hello" --output json', '--model qwq-plus --message "Solve 1+1" --enable-thinking', ], + validate: (f) => + !f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined, async run(config: Config, flags: GlobalFlags) { - const { system, messages: parsedMessages } = parseMessages(flags); - let messages = parsedMessages; - - if (messages.length === 0) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ - message: "Enter your message:", - }); - if (!hint) { - process.stderr.write("Chat cancelled.\n"); - process.exit(1); - } - messages = [{ role: "user", content: hint }]; - } else { - failIfMissing("message", cmdUsage(config, "--message ")); - } - } + const { system, messages } = parseMessages(flags); const model = (flags.model as string) || config.defaultTextModel || "qwen3.7-max"; const shouldStream = diff --git a/packages/commands/src/commands/usage/freetier.ts b/packages/commands/src/commands/usage/freetier.ts index f487d08..2302b5e 100644 --- a/packages/commands/src/commands/usage/freetier.ts +++ b/packages/commands/src/commands/usage/freetier.ts @@ -140,19 +140,13 @@ export default defineCommand({ "--off --model qwen3-max", "--off --all", ], + validate: (f) => + !f.model && !f.all ? "Provide --model [,model2,...] or --all." : undefined, async run(config: Config, flags: GlobalFlags) { const modelFlag = (flags.model as string) || undefined; - const all = Boolean(flags.all); const off = Boolean(flags.off); const format = detectOutputFormat(config.output); - if (!modelFlag && !all) { - process.stderr.write( - "Error: missing required flag. Specify --model [,model2,...] or --all\n", - ); - process.exit(1); - } - let models: string[]; if (modelFlag) { models = [ diff --git a/packages/commands/src/commands/video/download.ts b/packages/commands/src/commands/video/download.ts index 17f20d4..03f426d 100644 --- a/packages/commands/src/commands/video/download.ts +++ b/packages/commands/src/commands/video/download.ts @@ -10,7 +10,6 @@ import { ExitCode, } from "bailian-cli-core"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -18,19 +17,17 @@ export default defineCommand({ auth: "none", usageArgs: "--task-id --out ", options: [ - { flag: "--task-id ", description: "Task ID to download from" }, - { flag: "--out ", description: "Output file path" }, + { flag: "--task-id ", description: "Task ID to download from", required: true }, + { flag: "--out ", description: "Output file path", required: true }, ], exampleArgs: [ "--task-id 3b256896-xxxx --out video.mp4", "--task-id 3b256896-xxxx --out video.mp4 --quiet", ], async run(config: Config, flags: GlobalFlags) { - const taskId = flags.taskId as string | undefined; - if (!taskId) failIfMissing("task-id", cmdUsage(config, "--task-id --out ")); + const taskId = flags.taskId as string; - const outPath = flags.out as string | undefined; - if (!outPath) failIfMissing("out", cmdUsage(config, "--task-id --out video.mp4")); + const outPath = flags.out as string; const format = detectOutputFormat(config.output); diff --git a/packages/commands/src/commands/video/edit.ts b/packages/commands/src/commands/video/edit.ts index 18e8fb5..1539405 100644 --- a/packages/commands/src/commands/video/edit.ts +++ b/packages/commands/src/commands/video/edit.ts @@ -9,7 +9,6 @@ import { type DashScopeVideoEditRequest, type DashScopeAsyncResponse, type DashScopeTaskResponse, - isInteractive, resolveOutputDir, resolveFileUrl, resolveCredential, @@ -20,7 +19,6 @@ import { } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; @@ -59,10 +57,12 @@ export default defineCommand({ { flag: "--prompt-extend ", description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, + type: "boolean", }, { flag: "--watermark ", description: BOOL_FLAG_WATERMARK, + type: "boolean", }, { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, { flag: "--download ", description: "Save video to file on completion" }, @@ -84,34 +84,10 @@ export default defineCommand({ '--video https://example.com/input.mp4 --prompt "Put clothes on the kitten in the video" --watermark false', ], async run(config: Config, flags: GlobalFlags) { - // --- Validate video URL --- - let videoUrl = flags.video as string | undefined; - if (!videoUrl) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Enter the video URL to edit:" }); - if (!hint) { - process.stderr.write("Video editing cancelled.\n"); - process.exit(1); - } - videoUrl = hint; - } else { - failIfMissing("video", cmdUsage(config, "--video --prompt ")); - } - } + const videoUrl = flags.video as string; - // --- Prompt --- - let prompt = flags.prompt as string | undefined; - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Enter your edit instruction:" }); - if (!hint) { - process.stderr.write("Video editing cancelled.\n"); - process.exit(1); - } - prompt = hint; - } - // prompt is optional for video edit per API spec - } + // prompt is optional for video edit per API spec + const prompt = flags.prompt as string | undefined; const model = (flags.model as string) || "happyhorse-1.0-video-edit"; const format = detectOutputFormat(config.output); diff --git a/packages/commands/src/commands/video/generate.ts b/packages/commands/src/commands/video/generate.ts index 49ccd43..0ce249a 100644 --- a/packages/commands/src/commands/video/generate.ts +++ b/packages/commands/src/commands/video/generate.ts @@ -9,7 +9,6 @@ import { type DashScopeVideoRequest, type DashScopeAsyncResponse, type DashScopeTaskResponse, - isInteractive, resolveOutputDir, resolveFileUrl, resolveCredential, @@ -21,7 +20,6 @@ import { import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; import { runConcurrent, getConcurrency } from "bailian-cli-runtime"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; @@ -51,10 +49,12 @@ export default defineCommand({ { flag: "--prompt-extend ", description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, + type: "boolean", }, { flag: "--watermark ", description: BOOL_FLAG_WATERMARK, + type: "boolean", }, { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, { flag: "--download ", description: "Save video to file on completion" }, @@ -77,20 +77,7 @@ export default defineCommand({ '--prompt "A cat playing with a ball" --watermark false', ], async run(config: Config, flags: GlobalFlags) { - let prompt = flags.prompt as string | undefined; - - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ message: "Enter your video prompt:" }); - if (!hint) { - process.stderr.write("Video generation cancelled.\n"); - process.exit(1); - } - prompt = hint; - } else { - failIfMissing("prompt", cmdUsage(config, "--prompt ")); - } - } + const prompt = flags.prompt as string; const model = (flags.model as string) || diff --git a/packages/commands/src/commands/video/ref.ts b/packages/commands/src/commands/video/ref.ts index 5c2566e..cfadc22 100644 --- a/packages/commands/src/commands/video/ref.ts +++ b/packages/commands/src/commands/video/ref.ts @@ -9,7 +9,6 @@ import { type DashScopeVideoRefRequest, type DashScopeAsyncResponse, type DashScopeTaskResponse, - isInteractive, resolveOutputDir, resolveFileUrl, resolveCredential, @@ -20,7 +19,6 @@ import { } from "bailian-cli-core"; import { poll } from "bailian-cli-runtime"; import { downloadFile, formatBytes } from "bailian-cli-runtime"; -import { promptText, failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime"; @@ -66,10 +64,12 @@ export default defineCommand({ { flag: "--prompt-extend ", description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT, + type: "boolean", }, { flag: "--watermark ", description: BOOL_FLAG_WATERMARK, + type: "boolean", }, { flag: "--seed ", description: "Random seed for reproducible generation", type: "number" }, { flag: "--download ", description: "Save video to file on completion" }, @@ -91,35 +91,16 @@ export default defineCommand({ '--prompt "Image 1 and Image 2 have a conversation" --image a.jpg --image b.jpg --image-voice va.mp3 --image-voice vb.mp3', '--prompt "Image 1 drinks water" --image person.jpg --watermark false', ], + validate: (f) => + !(f.image as string[] | undefined)?.length && !(f.refVideo as string[] | undefined)?.length + ? "Provide at least one --image or --ref-video." + : undefined, async run(config: Config, flags: GlobalFlags) { - // --- Validate prompt --- - let prompt = flags.prompt as string | undefined; - if (!prompt) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ - message: "Enter your video prompt (use Image1, Video1 to reference inputs):", - }); - if (!hint) { - process.stderr.write("Video generation cancelled.\n"); - process.exit(1); - } - prompt = hint; - } else { - failIfMissing("prompt", cmdUsage(config, "--prompt --image ")); - } - } + const prompt = flags.prompt as string; const images = (flags.image as string[] | undefined) || []; const refVideos = (flags.refVideo as string[] | undefined) || []; - if (images.length === 0 && refVideos.length === 0) { - throw new BailianError( - "At least one --image or --ref-video is required.", - ExitCode.USAGE, - cmdUsage(config, '--prompt "description" --image person.jpg'), - ); - } - const imageVoices = (flags.imageVoice as string[] | undefined) || []; const videoVoices = (flags.videoVoice as string[] | undefined) || []; diff --git a/packages/commands/src/commands/video/task-get.ts b/packages/commands/src/commands/video/task-get.ts index a814f1f..5c1ea8f 100644 --- a/packages/commands/src/commands/video/task-get.ts +++ b/packages/commands/src/commands/video/task-get.ts @@ -7,21 +7,19 @@ import { type GlobalFlags, type DashScopeTaskResponse, } from "bailian-cli-core"; -import { failIfMissing, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; export default defineCommand({ description: "Query async task status", auth: "apiKey", usageArgs: "--task-id ", - options: [{ flag: "--task-id ", description: "Async task ID" }], + options: [{ flag: "--task-id ", description: "Async task ID", required: true }], exampleArgs: [ "--task-id 3b256896-3e70-xxxx-xxxx-xxxxxxxxxxxx", "--task-id 3b256896-3e70-xxxx --output json", ], async run(config: Config, flags: GlobalFlags) { - const taskId = flags.taskId as string | undefined; - if (!taskId) failIfMissing("task-id", cmdUsage(config, "--task-id ")); + const taskId = flags.taskId as string; const format = detectOutputFormat(config.output); diff --git a/packages/commands/src/commands/vision/describe.ts b/packages/commands/src/commands/vision/describe.ts index d2aa1bc..21fe701 100644 --- a/packages/commands/src/commands/vision/describe.ts +++ b/packages/commands/src/commands/vision/describe.ts @@ -8,14 +8,12 @@ import { type ChatRequest, type ChatResponse, type ChatMessageContent, - isInteractive, resolveFileUrl, resolveCredential, BailianError, ExitCode, isLocalFile, } from "bailian-cli-core"; -import { promptText, cmdUsage } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { readFileSync, existsSync } from "fs"; import { extname } from "path"; @@ -77,10 +75,12 @@ export default defineCommand({ "--video ./local-video.mp4", '--image photo.png --prompt "Extract the text" --model qwen-vl-plus', ], + validate: (f) => + !f.image && !(f.video as string[] | undefined)?.length + ? "Provide --image or --video." + : undefined, async run(config: Config, flags: GlobalFlags) { - let image = (flags.image ?? (flags._positional as string[] | undefined)?.[0]) as - | string - | undefined; + let image = flags.image as string | undefined; const videoInputs = (flags.video as string[] | undefined) ?? []; const model = (flags.model as string) || "qwen3-vl-plus"; @@ -94,30 +94,6 @@ export default defineCommand({ const defaultPrompt = hasVideo ? "Describe the video." : "Describe the image."; const prompt = (flags.prompt as string) || defaultPrompt; - if (!image && !hasVideo) { - if (isInteractive({ nonInteractive: config.nonInteractive })) { - const hint = await promptText({ - message: "Enter image/video path or URL:", - }); - if (!hint) { - process.stderr.write("Vision describe cancelled.\n"); - process.exit(1); - } - // Detect if user entered a video - if (isVideoInput(hint)) { - videoInputs.push(hint); - } else { - image = hint; - } - } else { - throw new BailianError( - "Missing required argument --image or --video.", - ExitCode.USAGE, - `${cmdUsage(config, "--image ")}\n${cmdUsage(config, "--video ")}`, - ); - } - } - const format = detectOutputFormat(config.output); if (config.dryRun) { diff --git a/packages/core/src/errors/base.ts b/packages/core/src/errors/base.ts index ebaaa4c..04fb116 100644 --- a/packages/core/src/errors/base.ts +++ b/packages/core/src/errors/base.ts @@ -45,6 +45,34 @@ export class BailianError extends Error { } } +/** + * The user invoked the CLI in a structurally invalid way: unknown command path, + * unknown/badly-typed flag, or a missing required argument. Always carries + * {@link ExitCode.USAGE}. The runtime's error boundary recognises this type and + * renders the relevant command's help before printing the message — so command + * code never builds usage strings or prints help itself; it just throws this. + */ +export class UsageError extends BailianError { + constructor(message: string, hint?: string) { + super(message, ExitCode.USAGE, hint); + this.name = "UsageError"; + } +} + +/** + * The command is *incomplete* — a valid prefix that stopped short (a missing + * required flag / positional). Distinct from {@link UsageError} ("you typed + * something wrong"): an incomplete command is not an error. The runtime's error + * boundary renders that command's help and exits 0 — exactly like landing on a + * command group with no subcommand. Carries {@link ExitCode.SUCCESS}. + */ +export class IncompleteCommandError extends BailianError { + constructor(message: string) { + super(message, ExitCode.SUCCESS); + this.name = "IncompleteCommandError"; + } +} + function serializeCause(cause: unknown): Record | undefined { if (cause == null) return undefined; if (cause instanceof Error) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cdd766a..d48357d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { BailianError } from "./errors/base.ts"; +export { BailianError, UsageError, IncompleteCommandError } from "./errors/base.ts"; export { mapApiError, type ApiErrorBody } from "./errors/api.ts"; export { ExitCode } from "./errors/codes.ts"; diff --git a/packages/core/src/output/formatter.ts b/packages/core/src/output/formatter.ts index f0da8fd..8184fc1 100644 --- a/packages/core/src/output/formatter.ts +++ b/packages/core/src/output/formatter.ts @@ -7,9 +7,6 @@ export function detectOutputFormat(flagValue?: string): OutputFormat { if (flagValue === "json" || flagValue === "text") { return flagValue; } - if (!process.stdout.isTTY) { - return "json"; - } return "text"; } diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 1605215..5443766 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -1,10 +1,20 @@ import type { Config } from "../config/schema.ts"; import type { GlobalFlags } from "./flags.ts"; +/** + * Flag value type, driving the parser. + * - string : `--flag ` → string (default). + * - number : `--flag ` → coerced + validated finite number. + * - boolean : `--flag true|false` → coerced boolean (a *value* flag). + * - switch : `--flag` → presence = true, takes NO value (`--flag=x` errors). + * - array : repeatable `--flag a --flag b` → string[]. + * Omitting `type`: a flag string with a `<…>`/`[…]` placeholder defaults to + * string, otherwise to switch. + */ export interface OptionDef { flag: string; description: string; - type?: "string" | "number" | "boolean" | "array"; + type?: "string" | "number" | "boolean" | "switch" | "array"; required?: boolean; } @@ -36,6 +46,15 @@ export interface Command { /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; notes?: string[]; + /** + * Cross-flag validation, run after parsing and before execute. Return an + * error message when the flag combination is incomplete (one-of, 3-of-N, + * value-conditional, dependency, …); the runtime throws IncompleteCommandError + * and renders this command's help. Return undefined to pass. Single-flag + * `required: true` is enforced by the parser — use this only for rules that + * span multiple flags or depend on a flag's *value*. + */ + validate?: (flags: GlobalFlags) => string | undefined; execute: (config: Config, flags: GlobalFlags) => Promise; } @@ -49,6 +68,8 @@ export interface CommandSpec { /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; notes?: string[]; + /** Cross-flag validation — see {@link Command.validate}. */ + validate?: (flags: GlobalFlags) => string | undefined; run: (config: Config, flags: GlobalFlags) => Promise; } @@ -60,6 +81,7 @@ export function defineCommand(spec: CommandSpec): Command { exampleArgs: spec.exampleArgs, auth: spec.auth, notes: spec.notes, + validate: spec.validate, execute: (config, flags) => spec.run(config, flags), }; } @@ -70,11 +92,11 @@ export const GLOBAL_OPTIONS: OptionDef[] = [ { flag: "--base-url ", description: "API base URL" }, { flag: "--output ", description: "Output format: text, json" }, { flag: "--timeout ", description: "Request timeout", type: "number" }, - { flag: "--quiet", description: "Suppress non-essential output" }, - { flag: "--verbose", description: "Print HTTP request/response details" }, - { flag: "--no-color", description: "Disable ANSI colors" }, - { flag: "--dry-run", description: "Dry run mode" }, - { flag: "--non-interactive", description: "Disable interactive prompts" }, + { flag: "--quiet", description: "Suppress non-essential output", type: "switch" }, + { flag: "--verbose", description: "Print HTTP request/response details", type: "switch" }, + { flag: "--no-color", description: "Disable ANSI colors", type: "switch" }, + { flag: "--dry-run", description: "Dry run mode", type: "switch" }, + { flag: "--non-interactive", description: "Disable interactive prompts", type: "switch" }, { flag: "--concurrent ", description: "Run N parallel requests (default: 1)", type: "number" }, { flag: "--console-region ", @@ -86,6 +108,6 @@ export const GLOBAL_OPTIONS: OptionDef[] = [ description: "Switch agent UID for delegated access", type: "number", }, - { flag: "--help", description: "Show help" }, - { flag: "--version", description: "Print version" }, + { flag: "--help", description: "Show help", type: "switch" }, + { flag: "--version", description: "Print version", type: "switch" }, ]; diff --git a/packages/core/src/types/flags.ts b/packages/core/src/types/flags.ts index 41e1a5a..7b21177 100644 --- a/packages/core/src/types/flags.ts +++ b/packages/core/src/types/flags.ts @@ -8,7 +8,6 @@ export interface GlobalFlags { noColor: boolean; yes: boolean; dryRun: boolean; - help: boolean; nonInteractive: boolean; async: boolean; consoleRegion?: string; diff --git a/packages/runtime/src/args.ts b/packages/runtime/src/args.ts index 16320cb..7e47a7f 100644 --- a/packages/runtime/src/args.ts +++ b/packages/runtime/src/args.ts @@ -1,6 +1,6 @@ import type { GlobalFlags } from "bailian-cli-core"; import type { OptionDef } from "bailian-cli-core"; -import { BailianError, ExitCode } from "bailian-cli-core"; +import { UsageError, IncompleteCommandError } from "bailian-cli-core"; function kebabToCamel(str: string): string { return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); @@ -12,14 +12,8 @@ function flagKey(def: OptionDef): string | null { return m ? kebabToCamel(m[1]!) : null; } -/** Boolean when no value placeholder and type is not string/number/array */ -function isBooleanDef(def: OptionDef): boolean { - if (def.type === "boolean") return true; - if (def.type === "string" || def.type === "number" || def.type === "array") return false; - return !def.flag.includes("<") && !def.flag.includes("["); -} - interface FlagSchema { + switches: Set; booleans: Set; numbers: Set; arrays: Set; @@ -34,152 +28,170 @@ function buildAllowedFlagKeys(options: OptionDef[]): Set { return keys; } +/** + * Classify each option by its declared (or inferred) type. Inference: a flag + * with a `<…>`/`[…]` placeholder defaults to string, otherwise to switch. + * Strings are the default bucket and need no set. + */ function buildSchema(options: OptionDef[]): FlagSchema { + const switches = new Set(); const booleans = new Set(); const numbers = new Set(); const arrays = new Set(); for (const opt of options) { const key = flagKey(opt); if (!key) continue; - if (isBooleanDef(opt)) booleans.add(key); - else if (opt.type === "number") numbers.add(key); - else if (opt.type === "array") arrays.add(key); + switch (opt.type) { + case "switch": + switches.add(key); + break; + case "boolean": + booleans.add(key); + break; + case "number": + numbers.add(key); + break; + case "array": + arrays.add(key); + break; + case "string": + break; + default: + if (!opt.flag.includes("<") && !opt.flag.includes("[")) switches.add(key); + } } - return { booleans, numbers, arrays }; + return { switches, booleans, numbers, arrays }; +} + +export interface ParsePathResult { + /** Command path: the leading run of bare tokens, e.g. ["speech", "recognize"]. */ + path: string[]; + /** Everything from the first flag onward — handed to parseFlags later. */ + rest: string[]; + hasHelpFlag: boolean; + hasVersionFlag: boolean; } /** - * Quick scan: collect positional (non-dash) args to determine the command path. - * Skips global flags and their values so that e.g. `--output json text chat` - * correctly produces ['text', 'chat'] instead of ['json', 'text', 'chat']. + * First pass — routing only. The command path is the leading run of bare + * (non-`-`) tokens; the first flag ends it ("command path first, then flags", + * oclif-style). There are no positionals, so nothing bare can legitimately + * follow a flag — and no flags precede the path, so this needs no schema. */ -export function scanCommandPath(argv: string[], globalOptions: OptionDef[] = []): string[] { - const globalSchema = buildSchema(globalOptions); - const path: string[] = []; +export function parsePath(argv: string[]): ParsePathResult { let i = 0; - while (i < argv.length) { - const arg = argv[i]!; - if (arg === "--") break; - - if (arg.startsWith("--")) { - const eqIdx = arg.indexOf("="); - const key = eqIdx !== -1 ? arg.slice(2, eqIdx) : arg.slice(2); - const camelKey = kebabToCamel(key); - - if (!globalSchema.booleans.has(camelKey) && eqIdx === -1) { - const next = argv[i + 1]; - // Command-local booleans (e.g. `--console`) are not in GLOBAL_OPTIONS; if the next - // token is another flag, do not consume it as this flag's value. - if (next === undefined || next.startsWith("-")) { - i += 1; - } else { - i += 2; - } - } else { - i += 1; - } - continue; - } - - if (arg.startsWith("-")) { - i++; - continue; - } - - path.push(arg); - i++; - } - return path; + while (i < argv.length && !argv[i]!.startsWith("-")) i++; + const rest = argv.slice(i); + return { + path: argv.slice(0, i), + rest, + hasHelpFlag: rest.includes("--help"), + hasVersionFlag: rest.includes("--version"), + }; } /** - * Full flag parse. Types are derived entirely from the provided OptionDef schema: - * - boolean: no placeholder in flag string (or type: 'boolean') - * - number: type: 'number' - * - array: type: 'array' (repeatable via multiple --flag occurrences) - * - default: string + * Second pass — parse the flag region into typed values, driven entirely by the + * OptionDef schema. Pure: returns typed flags or throws — never prints/exits. + * The runtime's error boundary decides rendering. Throws IncompleteCommandError + * for missing required flags (incomplete → help) and UsageError for malformed + * input (unknown/short flag, bad value, unexpected token, duplicate). */ -export function parseFlags(argv: string[], options: OptionDef[]): GlobalFlags { +export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags { const allowedKeys = buildAllowedFlagKeys(options); const schema = buildSchema(options); + const seen = new Set(); const flags: GlobalFlags = { quiet: false, verbose: false, noColor: false, yes: false, dryRun: false, - help: false, nonInteractive: false, async: false, }; let i = 0; - while (i < argv.length) { - const arg = argv[i]!; + while (i < rest.length) { + const arg = rest[i]!; - if (arg === "--help" || arg === "-h") { - flags.help = true; + if (!arg.startsWith("-")) { + throw new UsageError(`Unexpected argument: ${arg}`); + } + if (!arg.startsWith("--")) { + throw new UsageError(`Unknown flag "${arg}". Use the --long form.`); + } + + const eqIdx = arg.indexOf("="); + const key = eqIdx !== -1 ? arg.slice(2, eqIdx) : arg.slice(2); + let value: string | undefined = eqIdx !== -1 ? arg.slice(eqIdx + 1) : undefined; + + if (key === "") { + throw new UsageError(`Unknown flag "${arg}".`); + } + const camelKey = kebabToCamel(key); + if (!allowedKeys.has(camelKey)) { + throw new UsageError(`Unknown flag "--${key}". Run with --help to see available options.`); + } + + if (schema.switches.has(camelKey)) { + if (value !== undefined) { + throw new UsageError(`Flag --${key} is a switch and takes no value.`); + } + (flags as Record)[camelKey] = true; i++; continue; } - if (arg === "--") { - break; + + if (value === undefined) { + const next = rest[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new UsageError(`Flag --${key} requires a value.`); + } + value = next; + i += 2; + } else { + i += 1; } - if (arg.startsWith("--")) { - const eqIdx = arg.indexOf("="); - let key: string; - let value: string | undefined; - - if (eqIdx !== -1) { - key = arg.slice(2, eqIdx); - value = arg.slice(eqIdx + 1); - } else { - key = arg.slice(2); - } - - const camelKey = kebabToCamel(key); - - if (!allowedKeys.has(camelKey)) { - throw new BailianError( - `Unknown flag "--${key}". Run with --help to see available options.`, - ExitCode.USAGE, - ); - } - - // Switch-style flags (--quiet, --dry-run): no value. Value flags need a non-flag next token. - if (schema.booleans.has(camelKey)) { - (flags as Record)[camelKey] = true; - i++; - continue; - } - - // --prompt , --watermark , … - if (value === undefined) { - i++; - const next = argv[i]; - if (next === undefined || next.startsWith("-")) { - throw new BailianError(`Flag --${key} requires a value.`, ExitCode.USAGE); - } - value = next; - } - - if (schema.arrays.has(camelKey)) { - const arr = (flags as Record)[camelKey] as string[] | undefined; - if (arr) arr.push(value); - else (flags as Record)[camelKey] = [value]; - } else if (schema.numbers.has(camelKey)) { - const numericValue = Number(value); - if (!Number.isFinite(numericValue)) { - throw new BailianError(`Flag --${key} requires a finite number.`, ExitCode.USAGE); - } - (flags as Record)[camelKey] = numericValue; - } else { - (flags as Record)[camelKey] = value; - } + if (schema.arrays.has(camelKey)) { + const arr = (flags as Record)[camelKey] as string[] | undefined; + if (arr) arr.push(value); + else (flags as Record)[camelKey] = [value]; + continue; } - i++; + if (seen.has(camelKey)) { + throw new UsageError(`Flag --${key} given more than once.`); + } + seen.add(camelKey); + + if (schema.numbers.has(camelKey)) { + const n = Number(value); + if (!Number.isFinite(n)) { + throw new UsageError(`Flag --${key} requires a finite number.`); + } + (flags as Record)[camelKey] = n; + } else if (schema.booleans.has(camelKey)) { + const v = value.trim().toLowerCase(); + if (v === "true") (flags as Record)[camelKey] = true; + else if (v === "false") (flags as Record)[camelKey] = false; + else throw new UsageError(`Flag --${key} requires true or false.`); + } else { + (flags as Record)[camelKey] = value; + } + } + + const missing = options.filter((opt) => { + if (!opt.required) return false; + const key = flagKey(opt); + return key !== null && (flags as Record)[key] === undefined; + }); + if (missing.length > 0) { + const names = missing.map((opt) => opt.flag.match(/^(--[a-z][a-z0-9-]*)/i)?.[1] ?? opt.flag); + throw new IncompleteCommandError( + `Missing required ${names.length > 1 ? "flags" : "flag"}: ${names.join(", ")}`, + ); } return flags; diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 2738ccf..6502cd3 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -1,20 +1,24 @@ -import { scanCommandPath, parseFlags } from "./args.ts"; +import { parseFlags } from "./args.ts"; import { CommandRegistry } from "./registry.ts"; -import type { Command } from "bailian-cli-core"; +import { resolve } from "./resolve.ts"; +import { + compose, + authStage, + telemetryStage, + versionCheckStage, + runCommandStage, + type RunContext, +} from "./middleware.ts"; +import type { Command, Config, GlobalFlags } from "bailian-cli-core"; import { GLOBAL_OPTIONS, + IncompleteCommandError, loadConfig, - resolveCredential, - trackCommandExecution, flushTelemetry, } from "bailian-cli-core"; -import { ensureApiKey } from "./utils/ensure-key.ts"; import { setupProxyFromEnv } from "./proxy.ts"; import { handleError } from "./error-handler.ts"; -import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts"; -import { maybeShowStatusBar } from "./output/status-bar.ts"; import { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; -import { registerCommandHelpPrinter, setExecutingCommandPath } from "./utils/command-help.ts"; /** Per-product identity injected by each CLI entrypoint (bl / rag / …). */ export interface CliOptions { @@ -33,30 +37,25 @@ export interface Cli { } /** - * Build a CLI from an injected command set. The runtime is agnostic to *which* + * Build a CLI from an injected command set. The kernel is agnostic to *which* * commands exist — each product (bailian-cli, rag-cli, …) passes its own map and - * identity. No module-level singleton: the registry is scoped to this instance. + * identity. `run` is a thin orchestrator: resolve argv into a {@link Resolution}, + * then dispatch — terminal kinds render and return; `run` enters the middleware + * stack guarded by a single error boundary. No business `if`s, no scattered + * `process.exit`. */ export function createCli(commands: Record, opts: CliOptions): Cli { const registry = new CommandRegistry(commands, opts.binName); const clientName = opts.clientName ?? opts.binName; - const npmPackage = opts.npmPackage; - const version = opts.version; + const { binName, version, npmPackage } = opts; - // 必须在任何 fetch 发起前安装(含 update-checker / telemetry) try { setupProxyFromEnv(); } catch (err) { - handleError(err, opts.binName); + handleError(err, binName); } - registerCommandHelpPrinter((commandPath, out) => { - registry.printHelp(commandPath, out); - }); - // 优雅处理 Ctrl+C - // 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会 - // 落网络;flush 与较短超时 race,保证 SIGINT 仍然响应及时。 process.on("SIGINT", () => { process.stderr.write("\nInterrupted. Exiting.\n"); void flushTelemetry(500).finally(() => process.exit(130)); @@ -68,107 +67,89 @@ export function createCli(commands: Record, opts: CliOptions): else throw e; }); - async function main(): Promise { - let argv = process.argv.slice(2); - if (argv[0] === "--") argv = argv.slice(1); + const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]); - if (argv.includes("--version") || argv.includes("-v")) { - process.stdout.write(`${opts.binName} ${version}\n`); - process.exit(0); - } + function buildConfig(flags: GlobalFlags): Config { + const config = loadConfig(flags); + config.clientName = clientName; + config.clientVersion = version; + config.binName = binName; + config.npmPackage = npmPackage; + return config; + } - const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS); + /** Render help for `path`; root ([]) doubles as the onboarding / login guide. */ + function renderHelp(path: string[], argv: string[]): void { + registry.printHelp(path, process.stderr); + if (path.length > 0) return; - if (argv.includes("--help") || argv.includes("-h")) { - registry.printHelp(commandPath, process.stderr); - process.exit(0); - } - - // 未传任何命令:展示帮助信息与登录引导 - if (commandPath.length === 0) { - registry.printHelp([], process.stderr); - - const flags = parseFlags(argv, GLOBAL_OPTIONS); - const config = loadConfig(flags); - config.clientName = clientName; - config.clientVersion = version; - config.binName = opts.binName; - config.npmPackage = npmPackage; - - const hasKey = !!( + let hasKey = false; + try { + const config = buildConfig(parseFlags(argv, GLOBAL_OPTIONS)); + hasKey = !!( config.apiKey || config.fileApiKey || config.fileAccessToken || config.accessTokenEnv ); - if (hasKey) printQuickStart(); - else printWelcomeBanner(opts.binName); - process.exit(0); + } catch { + /* unparseable global flags on the bare invocation — fall through to welcome */ } + if (hasKey) printQuickStart(); + else printWelcomeBanner(binName); + } - // 组路径(例如 `bl speech` 未接子命令):展示帮助后干净退出 - if (registry.isGroupPath(commandPath)) { - registry.printHelp(commandPath, process.stderr); - process.exit(0); - } + async function dispatch(argv: string[]): Promise { + const res = resolve(argv, registry); - const { command, extra } = registry.resolve(commandPath); - const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]); + switch (res.kind) { + case "version": + process.stdout.write(`${binName} ${version}\n`); + return; - if (extra.length > 0) (flags as Record)._positional = extra; + case "help": + renderHelp(res.path, argv); + return; - const config = loadConfig(flags); - config.clientName = clientName; - config.clientVersion = version; - config.binName = opts.binName; - config.npmPackage = npmPackage; + case "usageError": + handleError(res.error, binName); + return; - // 仅 apiKey 类命令由框架统一准备 API Key;dry-run 时跳过(只打印请求,不要求凭证)。 - // console 命令在命令体内解析 Console Gateway 凭证;none 命令无需凭证。 - if (command.auth === "apiKey" && !config.dryRun) { - await ensureApiKey(config); - try { - const credential = await resolveCredential(config); - maybeShowStatusBar(config, credential.token, credential); - } catch { - /* 没有凭证,不展示状态栏 */ + case "run": { + try { + const flags = parseFlags(res.rest, [...GLOBAL_OPTIONS, ...(res.command.options ?? [])]); + const invalid = res.command.validate?.(flags); + if (invalid) throw new IncompleteCommandError(invalid); + const config = buildConfig(flags); + const ctx: RunContext = { + binName, + version, + npmPackage, + path: res.path, + command: res.command, + config, + flags, + }; + await runMiddleware(ctx); + await flushTelemetry(1000); + } catch (err) { + await flushTelemetry(1000); + if (err instanceof IncompleteCommandError) { + registry.printHelp(res.path, process.stderr); + return; + } + handleError(err, binName); + } + return; } } - - const updateCheckPromise = checkForUpdate(version, npmPackage).catch(() => {}); - - setExecutingCommandPath(commandPath); - - await trackCommandExecution(config, commandPath, flags, () => command.execute(config, flags)); - - await updateCheckPromise; - const isUpdateCommand = commandPath.length === 1 && commandPath[0] === "update"; - const newVersion = getPendingUpdateNotification(); - if (newVersion && !config.quiet && !isUpdateCommand) { - const isTTY = process.stderr.isTTY; - const yellow = isTTY ? "\x1b[33m" : ""; - const cyan = isTTY ? "\x1b[36m" : ""; - const reset = isTTY ? "\x1b[0m" : ""; - process.stderr.write(`\n ${yellow}Update available: ${version} → ${newVersion}${reset}\n`); - process.stderr.write(` Run ${cyan}${opts.binName} update${reset} to upgrade\n\n`); - } - - // 进程退出前尽力等待在途的埋点完成。 - // 使用较短超时兜底,避免慢网拖慢用户感知。 - await flushTelemetry(1000); } return { - run() { - return main().catch((err) => { - // 在 handleError() 调用 process.exit() 之前刷出在途埋点。 - // 命令抛出的错误已被 trackCommandExecution 的 finally 块记录, - // 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话, - // 错误事件会随进程退出丢掉。 - return flushTelemetry(1000).finally(() => - handleError(err, opts.binName), - ) as unknown as void; - }); + run(argv: string[] = process.argv.slice(2)) { + return dispatch(argv).catch( + (err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void, + ); }, }; } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 790df67..3ade7f5 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -7,10 +7,14 @@ export type { Cli, CliOptions } from "./create-cli.ts"; // Command routing export { CommandRegistry } from "./registry.ts"; -export type { Command, OptionDef } from "./registry.ts"; +export type { Command, OptionDef, LocateResult } from "./registry.ts"; +export { resolve } from "./resolve.ts"; +export type { Resolution } from "./resolve.ts"; +export { compose, type RunContext, type Middleware } from "./middleware.ts"; // Arg parsing -export { scanCommandPath, parseFlags } from "./args.ts"; +export { parsePath, parseFlags } from "./args.ts"; +export type { ParsePathResult } from "./args.ts"; // Process-level setup / error handling export { setupProxyFromEnv } from "./proxy.ts"; @@ -22,13 +26,7 @@ export { BAILIAN_CONSOLE_ROOT, BAILIAN_CONSOLE, API_KEY_PAGE } from "./urls.ts"; // Output facilities consumed by commands export { emitResult, emitBare } from "./output/output.ts"; -export { - promptText, - promptSelect, - promptConfirm, - failIfMissing, - cmdUsage, -} from "./output/prompt.ts"; +export { promptText, promptSelect, promptConfirm, cmdUsage } from "./output/prompt.ts"; export { createSpinner, createProgressBar } from "./output/progress.ts"; export { printWelcomeBanner, printQuickStart } from "./output/banner.ts"; export { maybeShowStatusBar } from "./output/status-bar.ts"; @@ -40,12 +38,6 @@ export { downloadFile, formatBytes } from "./utils/download.ts"; export { runConcurrent, getConcurrency, downloadParallel } from "./utils/concurrent.ts"; export { resolveImageSize } from "./utils/image-size.ts"; export { ensureApiKey } from "./utils/ensure-key.ts"; -export { - printCurrentCommandHelp, - setExecutingCommandPath, - getExecutingCommandPath, - registerCommandHelpPrinter, -} from "./utils/command-help.ts"; export { checkForUpdate, getPendingUpdateNotification, diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts new file mode 100644 index 0000000..3197653 --- /dev/null +++ b/packages/runtime/src/middleware.ts @@ -0,0 +1,83 @@ +import type { Command, Config, GlobalFlags } from "bailian-cli-core"; +import { resolveCredential, trackCommandExecution } from "bailian-cli-core"; +import { ensureApiKey } from "./utils/ensure-key.ts"; +import { maybeShowStatusBar } from "./output/status-bar.ts"; +import { checkForUpdate, getPendingUpdateNotification } from "./utils/update-checker.ts"; + +/** + * Everything a stage needs about the invocation in flight. Built once per `run` + * by the kernel and threaded through the middleware stack. The command itself + * still receives `(config, flags)` — this context is the pipeline's, not the + * command's — so adding cross-cutting concerns never touches command code. + */ +export interface RunContext { + readonly binName: string; + readonly version: string; + readonly npmPackage: string; + /** The matched command path, e.g. ["speech","recognize"]. */ + readonly path: string[]; + readonly command: Command; + config: Config; + flags: GlobalFlags; +} + +/** Koa-style onion middleware: do work, call `next()`, do work after it returns. */ +export type Middleware = (ctx: RunContext, next: () => Promise) => Promise; + +/** Fold a middleware list into a single runnable function. */ +export function compose(stack: Middleware[]): (ctx: RunContext) => Promise { + return (ctx) => { + const dispatch = (i: number): Promise => { + const mw = stack[i]; + if (!mw) return Promise.resolve(); + return mw(ctx, () => dispatch(i + 1)); + }; + return dispatch(0); + }; +} + +/** + * Prepare credentials for commands that need an API key. console / none + * commands resolve their own (or no) credential inside the command body. + */ +export const authStage: Middleware = async (ctx, next) => { + if (ctx.command.auth === "apiKey" && !ctx.config.dryRun) { + await ensureApiKey(ctx.config); + try { + const credential = await resolveCredential(ctx.config); + maybeShowStatusBar(ctx.config, credential.token, credential); + } catch { + /* no credential resolved — skip the status bar */ + } + } + await next(); +}; + +/** Record command execution (start / success / failure) around the command. */ +export const telemetryStage: Middleware = (ctx, next) => + trackCommandExecution(ctx.config, ctx.path, ctx.flags, next); + +/** + * Kick off a debounced update check before the command, then — only on success + * — surface any pending notification. Never affects the command's exit code: + * if `next()` throws, the notice is skipped (no update nag on failure). + */ +export const versionCheckStage: Middleware = async (ctx, next) => { + const pending = checkForUpdate(ctx.version, ctx.npmPackage).catch(() => {}); + await next(); + await pending; + + const isUpdateCommand = ctx.path.length === 1 && ctx.path[0] === "update"; + const newVersion = getPendingUpdateNotification(); + if (newVersion && !ctx.config.quiet && !isUpdateCommand) { + const isTTY = process.stderr.isTTY; + const yellow = isTTY ? "\x1b[33m" : ""; + const cyan = isTTY ? "\x1b[36m" : ""; + const reset = isTTY ? "\x1b[0m" : ""; + process.stderr.write(`\n ${yellow}Update available: ${ctx.version} → ${newVersion}${reset}\n`); + process.stderr.write(` Run ${cyan}${ctx.binName} update${reset} to upgrade\n\n`); + } +}; + +/** Innermost stage: hand control to the command. */ +export const runCommandStage: Middleware = (ctx) => ctx.command.execute(ctx.config, ctx.flags); diff --git a/packages/runtime/src/output/prompt.ts b/packages/runtime/src/output/prompt.ts index bc1d825..012ca9c 100644 --- a/packages/runtime/src/output/prompt.ts +++ b/packages/runtime/src/output/prompt.ts @@ -10,18 +10,16 @@ * case explicitly. */ -import { BailianError, ExitCode, isInteractive, type Config } from "bailian-cli-core"; -import { printCurrentCommandHelp, getExecutingCommandPath } from "../utils/command-help.ts"; +import { isInteractive, type Config } from "bailian-cli-core"; /** - * Build a command-usage string for the running command: ` `. - * Both the product binary name and the command path come from the runtime, so - * callers never hardcode "bl" or their own path — the same code renders as - * `bl knowledge retrieve …` under bl and `rag retrieve …` under rag. + * Build a command-usage string prefixed with the product binary name, e.g. + * `bl --list-voices --model x`. Used for actionable hints inside error messages + * (the error boundary renders full command help separately). */ export function cmdUsage(config: Config, args = ""): string { - const parts = [config.binName, ...getExecutingCommandPath()].filter(Boolean); - return args ? `${parts.join(" ")} ${args}` : parts.join(" "); + const bin = config.binName ?? ""; + return args ? `${bin} ${args}` : bin; } // Dynamic import to avoid loading @clack/prompts in non-interactive envs unnecessarily @@ -105,21 +103,3 @@ export async function promptSelect(options: { if (typeof val === "symbol") return undefined; return val as string; } - -/** - * Fail fast with a user-friendly error when a required option is missing - * in non-interactive (agent / CI) mode. - */ -export function failIfMissing(flagName: string, context: string): never { - if (getExecutingCommandPath().length > 0) { - printCurrentCommandHelp(process.stderr); - process.exit(0); - } - throw new BailianError( - `Missing required argument: --${flagName}\n` + - `Hint: In non-interactive (CI / agent) environments all required flags must be provided.\n` + - ` In an interactive terminal, run without --${flagName} and the CLI will prompt for it.`, - ExitCode.USAGE, - context, - ); -} diff --git a/packages/runtime/src/registry.ts b/packages/runtime/src/registry.ts index ef23fe8..5155ec2 100644 --- a/packages/runtime/src/registry.ts +++ b/packages/runtime/src/registry.ts @@ -1,6 +1,5 @@ import type { Command } from "bailian-cli-core"; -import { BailianError } from "bailian-cli-core"; -import { ExitCode } from "bailian-cli-core"; +import { UsageError } from "bailian-cli-core"; import { GLOBAL_OPTIONS } from "bailian-cli-core"; export type { Command, OptionDef } from "bailian-cli-core"; @@ -10,6 +9,19 @@ interface CommandNode { children: Map; } +/** + * What a command path resolves to in the registry. The single judgement that + * feeds `resolve()` — no scattered `isGroupPath` + throwing `resolve`. + * - leaf: landed *exactly* on an executable command (no leftover tokens). + * - group: landed on a command group with no executable of its own (incl. root []). + * - unknown: the path doesn't exist, or a valid command had unexpected trailing + * tokens (no positionals); `error` carries the message + hint. + */ +export type LocateResult = + | { kind: "leaf"; command: Command; matched: string[] } + | { kind: "group"; matched: string[] } + | { kind: "unknown"; error: UsageError }; + export class CommandRegistry { private root: CommandNode = { children: new Map() }; /** Binary name shown in usage/help/error strings (e.g. "bl", "rag"). */ @@ -59,17 +71,13 @@ export class CommandRegistry { return walk(this.root, []) ?? " "; } - isGroupPath(commandPath: string[]): boolean { - let node = this.root; - for (const part of commandPath) { - const child = node.children.get(part); - if (!child) return false; - node = child; - } - return !node.command && node.children.size > 0; - } - - resolve(commandPath: string[]): { command: Command; extra: string[] } { + /** + * Resolve a command path to a leaf / group / unknown outcome. Pure: walks the + * trie taking the longest registered prefix as the command. There are no + * positionals, so a valid command followed by leftover tokens is `unknown` + * (unexpected argument). Never throws — unknown paths return a carried UsageError. + */ + locate(commandPath: string[]): LocateResult { let node = this.root; const matched: string[] = []; @@ -81,18 +89,23 @@ export class CommandRegistry { } if (node.command) { - return { command: node.command, extra: commandPath.slice(matched.length) }; - } - - // Single child: auto-forward (e.g. `bl config` → `bl config show`) - if (matched.length > 0 && node.children.size === 1) { - const [, child] = node.children.entries().next().value as [string, CommandNode]; - if (child.command) { - return { command: child.command, extra: commandPath.slice(matched.length) }; + const leftover = commandPath.slice(matched.length); + if (leftover.length === 0) { + return { kind: "leaf", command: node.command, matched }; } + return { + kind: "unknown", + error: new UsageError( + `Unexpected argument: ${leftover.join(" ")}`, + `${this.cliName} ${matched.join(" ")} --help`, + ), + }; + } + + if (matched.length === commandPath.length && node.children.size > 0) { + return { kind: "group", matched }; } - // If we matched some path but no command, show help for that group if (matched.length > 0 && node.children.size > 0) { const subcommands = Array.from(node.children.entries()) .map(([name, n]) => { @@ -101,18 +114,22 @@ export class CommandRegistry { return ` ${matched.join(" ")} ${name} [${subs}]`; }) .join("\n"); - throw new BailianError( - `Unknown command: ${this.cliName} ${commandPath.join(" ")}\n\nAvailable commands:\n${subcommands}`, - ExitCode.USAGE, - `${this.cliName} ${matched.join(" ")} --help`, - ); + return { + kind: "unknown", + error: new UsageError( + `Unknown command: ${this.cliName} ${commandPath.join(" ")}\n\nAvailable commands:\n${subcommands}`, + `${this.cliName} ${matched.join(" ")} --help`, + ), + }; } - throw new BailianError( - `Unknown command: ${this.cliName} ${commandPath.join(" ")}`, - ExitCode.USAGE, - `${this.cliName} --help`, - ); + return { + kind: "unknown", + error: new UsageError( + `Unknown command: ${this.cliName} ${commandPath.join(" ")}`, + `${this.cliName} --help`, + ), + }; } private buildResourceLines(a: (s: string) => string, d: (s: string) => string): string { diff --git a/packages/runtime/src/resolve.ts b/packages/runtime/src/resolve.ts new file mode 100644 index 0000000..263f132 --- /dev/null +++ b/packages/runtime/src/resolve.ts @@ -0,0 +1,41 @@ +import type { Command } from "bailian-cli-core"; +import { type UsageError } from "bailian-cli-core"; +import type { CommandRegistry } from "./registry.ts"; +import { parsePath } from "./args.ts"; + +/** + * What an invocation resolves to — "what to do" expressed as data, not control + * flow. A single pure function (`resolve`) produces one of these; `createCli`'s + * dispatch switches on it. No scattered `argv.includes(...)` + `process.exit`. + * - version: print the version and stop. + * - help: print help for `path` (root [] / a group / an explicit --help). Terminal. + * - run: execute `command`; `rest` is the flag region for parseFlags. + * - usageError: the path doesn't exist (or has unexpected args); render and stop. + */ +export type Resolution = + | { kind: "version" } + | { kind: "help"; path: string[] } + | { kind: "run"; path: string[]; command: Command; rest: string[] } + | { kind: "usageError"; error: UsageError }; + +/** + * Classify argv into a {@link Resolution}. Pure over (argv, registry): one + * `parsePath` scan for the command path + flag region, one `registry.locate` + * for the routing decision. Never throws. Trivially unit-testable. + */ +export function resolve(argv: string[], registry: CommandRegistry): Resolution { + const { path, rest, hasHelpFlag, hasVersionFlag } = parsePath(argv); + if (hasVersionFlag) return { kind: "version" }; + + const target = registry.locate(path); + switch (target.kind) { + case "leaf": + return hasHelpFlag + ? { kind: "help", path: target.matched } + : { kind: "run", path: target.matched, command: target.command, rest }; + case "group": + return { kind: "help", path: target.matched }; + case "unknown": + return { kind: "usageError", error: target.error }; + } +} diff --git a/packages/runtime/src/utils/command-help.ts b/packages/runtime/src/utils/command-help.ts deleted file mode 100644 index e51feb3..0000000 --- a/packages/runtime/src/utils/command-help.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Current command path (e.g. `["auth","login"]`) for help-on-missing; set by `main` before `execute`. */ -let executingCommandPath: string[] = []; - -let printCommandHelpImpl: ((commandPath: string[], out: NodeJS.WriteStream) => void) | null = null; - -export function setExecutingCommandPath(path: string[]): void { - executingCommandPath = path; -} - -export function getExecutingCommandPath(): string[] { - return executingCommandPath; -} - -export function registerCommandHelpPrinter( - fn: (commandPath: string[], out: NodeJS.WriteStream) => void, -): void { - printCommandHelpImpl = fn; -} - -/** Print help for the command currently being executed (must call `setExecutingCommandPath` first). */ -export function printCurrentCommandHelp(out: NodeJS.WriteStream = process.stderr): void { - if (printCommandHelpImpl && executingCommandPath.length > 0) { - printCommandHelpImpl(executingCommandPath, out); - } -} diff --git a/packages/runtime/tests/args.test.ts b/packages/runtime/tests/args.test.ts index b32d208..5b78bcd 100644 --- a/packages/runtime/tests/args.test.ts +++ b/packages/runtime/tests/args.test.ts @@ -1,95 +1,140 @@ import { expect, test } from "vite-plus/test"; -import { ExitCode, GLOBAL_OPTIONS } from "bailian-cli-core"; -import { parseFlags } from "../src/args.ts"; -import { BOOL_FLAG_WATERMARK } from "../src/utils/flag-descriptions.ts"; +import { ExitCode, GLOBAL_OPTIONS, type OptionDef } from "bailian-cli-core"; +import { parsePath, parseFlags } from "../src/args.ts"; -const IMAGE_GENERATE_OPTIONS = [ +const IMAGE_GENERATE_OPTIONS: OptionDef[] = [ { flag: "--prompt ", description: "Image description", required: true }, { flag: "--model ", description: "Model ID" }, - { flag: "--watermark ", description: BOOL_FLAG_WATERMARK }, - { flag: "--no-wait", description: "Return task ID immediately without waiting" }, + { flag: "--image ", description: "Image URL (repeatable)", type: "array" }, + { flag: "--n ", description: "Number of images", type: "number" }, + { flag: "--watermark ", description: "Watermark", type: "boolean" }, + { flag: "--no-wait", description: "Return immediately", type: "switch" }, ]; +const OPTS = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; + +// ---- parsePath: routing only (command path first, then flags) ---- + +test("parsePath splits leading bare tokens as the command path", () => { + const r = parsePath(["image", "generate", "--prompt", "cat"]); + expect(r.path).toEqual(["image", "generate"]); + expect(r.rest).toEqual(["--prompt", "cat"]); +}); + +test("parsePath stops the path at the first flag", () => { + const r = parsePath(["speech"]); + expect(r.path).toEqual(["speech"]); + expect(r.rest).toEqual([]); +}); + +test("parsePath detects --help and --version in the flag region", () => { + expect(parsePath(["image", "generate", "--help"]).hasHelpFlag).toBe(true); + const v = parsePath(["--version"]); + expect(v.hasVersionFlag).toBe(true); + expect(v.path).toEqual([]); +}); + +// ---- parseFlags: typed parsing ---- + +test("parseFlags parses string / number / switch", () => { + const flags = parseFlags(["--prompt", "cat", "--n", "3", "--no-wait"], OPTS); + expect(flags.prompt).toBe("cat"); + expect(flags.n).toBe(3); + expect(flags.noWait).toBe(true); +}); + +test("parseFlags supports the --flag=value form", () => { + expect(parseFlags(["--prompt=cat"], OPTS).prompt).toBe("cat"); +}); + +test("parseFlags coerces boolean flags to real booleans", () => { + expect(parseFlags(["--prompt", "x", "--watermark", "false"], OPTS).watermark).toBe(false); + expect(parseFlags(["--prompt", "x", "--watermark=true"], OPTS).watermark).toBe(true); +}); + +test("parseFlags collects repeated array flags", () => { + expect(parseFlags(["--prompt", "x", "--image", "a", "--image", "b"], OPTS).image).toEqual([ + "a", + "b", + ]); +}); + +test("parseFlags accepts a lone - (stdin) and negative numbers as values", () => { + expect(parseFlags(["--prompt", "x", "--model", "-"], OPTS).model).toBe("-"); + expect(parseFlags(["--prompt", "x", "--n", "-5"], OPTS).n).toBe(-5); +}); + +// ---- parseFlags: validation (all UsageError = exit 2) ---- + +test("parseFlags rejects a non true/false boolean value", () => { + expect(() => parseFlags(["--prompt", "x", "--watermark", "yes"], OPTS)).toThrowError( + expect.objectContaining({ + name: "UsageError", + message: expect.stringContaining("true or false"), + }), + ); +}); + +test("parseFlags rejects a repeated non-array flag", () => { + expect(() => parseFlags(["--prompt", "a", "--prompt", "b"], OPTS)).toThrowError( + expect.objectContaining({ + name: "UsageError", + message: expect.stringContaining("more than once"), + }), + ); +}); + +test("parseFlags rejects a switch given a value", () => { + expect(() => parseFlags(["--prompt", "x", "--no-wait=true"], OPTS)).toThrowError( + expect.objectContaining({ + name: "UsageError", + message: expect.stringContaining("takes no value"), + }), + ); +}); test("parseFlags rejects unknown long flags", () => { - expect(() => - parseFlags(["--prompt", "cat", "--xxxx", "a"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]), - ).toThrowError( + expect(() => parseFlags(["--prompt", "cat", "--xxxx", "a"], OPTS)).toThrowError( expect.objectContaining({ - name: "BailianError", + name: "UsageError", exitCode: ExitCode.USAGE, message: expect.stringContaining('Unknown flag "--xxxx"'), }), ); }); -test("parseFlags rejects unknown flags with = syntax", () => { - expect(() => - parseFlags( - ["--prompt=cat", "--unknown-flag=yes"], - [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS], - ), - ).toThrow(/Unknown flag "--unknown-flag"/); -}); - -test("parseFlags accepts defined command and global flags", () => { - const flags = parseFlags( - ["--quiet", "--prompt", "cat", "--watermark", "false"], - [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS], +test("parseFlags rejects short flags", () => { + expect(() => parseFlags(["--prompt", "x", "-h"], OPTS)).toThrowError( + expect.objectContaining({ name: "UsageError" }), ); - expect(flags.quiet).toBe(true); - expect(flags.prompt).toBe("cat"); - expect(flags.watermark).toBe("false"); }); -test("parseFlags rejects value flag when next token is another flag", () => { - const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; - for (const argv of [ - ["--watermark", "--prompt", "cat"], - ["--watermark", "-h"], - ["--prompt", "cat", "--watermark", "--model", "qwen-image-2.0"], - ]) { - expect(() => parseFlags(argv, opts)).toThrowError( - expect.objectContaining({ - name: "BailianError", - exitCode: ExitCode.USAGE, - message: expect.stringContaining("Flag --watermark requires a value"), - }), - ); - } -}); - -test("parseFlags rejects trailing value flag without value", () => { - expect(() => - parseFlags(["--prompt", "cat", "--watermark"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]), - ).toThrowError( +test("parseFlags rejects unexpected bare tokens (no positionals)", () => { + expect(() => parseFlags(["--prompt", "x", "stray"], OPTS)).toThrowError( expect.objectContaining({ - message: expect.stringContaining("Flag --watermark requires a value"), + name: "UsageError", + message: expect.stringContaining("Unexpected argument"), }), ); }); -test("parseFlags allows boolean flags without values adjacent to other flags", () => { - const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; - const flags = parseFlags( - ["--quiet", "--dry-run", "--no-wait", "--prompt", "cat", "--watermark", "false"], - opts, +test("parseFlags rejects a value flag whose value is missing", () => { + expect(() => parseFlags(["--prompt", "cat", "--model"], OPTS)).toThrowError( + expect.objectContaining({ message: expect.stringContaining("Flag --model requires a value") }), ); - expect(flags.quiet).toBe(true); - expect(flags.dryRun).toBe(true); - expect(flags.noWait).toBe(true); - expect(flags.prompt).toBe("cat"); - expect(flags.watermark).toBe("false"); }); -test("parseFlags does not treat the next flag as a boolean flag value", () => { - const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; - expect(() => parseFlags(["--dry-run", "--prompt"], opts)).toThrowError( +test("parseFlags validates number flags", () => { + expect(() => parseFlags(["--prompt", "x", "--n", "abc"], OPTS)).toThrowError( + expect.objectContaining({ message: expect.stringContaining("finite number") }), + ); +}); + +test("parseFlags throws IncompleteCommandError when a required flag is missing", () => { + expect(() => parseFlags(["--model", "qwen-image-2.0"], OPTS)).toThrowError( expect.objectContaining({ - message: expect.stringContaining("Flag --prompt requires a value"), + name: "IncompleteCommandError", + exitCode: ExitCode.SUCCESS, + message: expect.stringContaining("Missing required flag: --prompt"), }), ); - // --dry-run is boolean: no value check; parsing continues to --prompt. - const flags = parseFlags(["--dry-run", "--prompt", "cat"], opts); - expect(flags.dryRun).toBe(true); - expect(flags.prompt).toBe("cat"); }); diff --git a/skills/bailian-cli/reference/advisor.md b/skills/bailian-cli/reference/advisor.md index 306957f..f1f231b 100644 --- a/skills/bailian-cli/reference/advisor.md +++ b/skills/bailian-cli/reference/advisor.md @@ -19,15 +19,13 @@ Index: [index.md](index.md) | --------------- | ---------------------------------------------------------------------------------------------- | | **Name** | `advisor recommend` | | **Description** | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | -| **Usage** | `bl advisor recommend [flags]` | +| **Usage** | `bl advisor recommend --message [flags]` | #### Options -| Flag | Type | Required | Description | -| ------------------- | ------- | -------- | ------------------------------------------------------------- | -| `--message ` | string | no | Describe your requirements (alternative to positional prompt) | -| `--dry-run` | boolean | no | Show intent analysis and candidate list without LLM ranking | -| `--output ` | string | no | Output format: text (default in TTY), json, yaml | +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | -------------------------- | +| `--message ` | string | yes | Describe your requirements | #### Examples @@ -50,7 +48,3 @@ bl advisor recommend --message "Low-cost high-concurrency online customer servic ```bash bl advisor recommend --message "Long document summarization" --dry-run ``` - -```bash -bl advisor recommend # Interactive input -``` diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index ae92f90..1822e1c 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -26,8 +26,8 @@ Index: [index.md](index.md) | Flag | Type | Required | Description | | ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `--key ` | string | no | Config key (base*url, output, output_dir, timeout, api_key, access_token, default*\*\_model, access_key_id, access_key_secret, workspace_id) | -| `--value ` | string | no | Value to set | +| `--key ` | string | yes | Config key (base*url, output, output_dir, timeout, api_key, access_token, default*\*\_model, access_key_id, access_key_secret, workspace_id) | +| `--value ` | string | yes | Value to set | #### Examples diff --git a/skills/bailian-cli/reference/image.md b/skills/bailian-cli/reference/image.md index d9e9490..50e81ca 100644 --- a/skills/bailian-cli/reference/image.md +++ b/skills/bailian-cli/reference/image.md @@ -24,19 +24,19 @@ Index: [index.md](index.md) #### Options -| Flag | Type | Required | Description | -| -------------------------- | ------ | -------- | ----------------------------------------------------------------------- | -| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | -| `--prompt ` | string | yes | Edit instruction text | -| `--model ` | string | no | Model ID (default: qwen-image-2.0) | -| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | -| `--n ` | number | no | Number of images (default: 1, max: 6) | -| `--seed ` | number | no | Random seed for reproducible results | -| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--prompt-extend ` | string | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | -| `--watermark ` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). | -| `--out-dir ` | string | no | Download images to directory | -| `--out-prefix ` | string | no | Filename prefix (default: edited) | +| Flag | Type | Required | Description | +| -------------------------- | ------- | -------- | ----------------------------------------------------------------------- | +| `--image ` | array | yes | Source image URL or local file path (repeatable for multi-image merge) | +| `--prompt ` | string | yes | Edit instruction text | +| `--model ` | string | no | Model ID (default: qwen-image-2.0) | +| `--size ` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) | +| `--n ` | number | no | Number of images (default: 1, max: 6) | +| `--seed ` | number | no | Random seed for reproducible results | +| `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--out-dir ` | string | no | Download images to directory | +| `--out-prefix ` | string | no | Filename prefix (default: edited) | #### Examples @@ -78,8 +78,8 @@ bl image edit --image ./photo.png --prompt "Replace the background with a beach" | `--n ` | number | no | Number of images per request (default: 1, max: 6) | | `--seed ` | number | no | Random seed for reproducible generation | | `--negative-prompt ` | string | no | Negative prompt to exclude unwanted content | -| `--prompt-extend ` | string | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). | -| `--watermark ` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | | `--no-wait` | boolean | no | Return task ID immediately without waiting (async models only) | | `--out-dir ` | string | no | Download images to directory | | `--out-prefix ` | string | no | Filename prefix (default: image) | diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 13478ff..a9d5cc0 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -86,23 +86,23 @@ Use this index for the full quick index and global flags. Available on every command (in addition to command-specific options): -| Flag | Type | Required | Description | -| ------------------------------ | ------- | -------- | -------------------------------------------------------- | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | -| `--output ` | string | no | Output format: text, json | -| `--timeout ` | number | no | Request timeout | -| `--quiet` | boolean | no | Suppress non-essential output | -| `--verbose` | boolean | no | Print HTTP request/response details | -| `--no-color` | boolean | no | Disable ANSI colors | -| `--dry-run` | boolean | no | Dry run mode | -| `--non-interactive` | boolean | no | Disable interactive prompts | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | -| `--console-site ` | string | no | Console site: domestic, international | -| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | -| `--help` | boolean | no | Show help | -| `--version` | boolean | no | Print version | +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | -------------------------------------------------------- | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | +| `--output ` | string | no | Output format: text, json | +| `--timeout ` | number | no | Request timeout | +| `--quiet` | switch | no | Suppress non-essential output | +| `--verbose` | switch | no | Print HTTP request/response details | +| `--no-color` | switch | no | Disable ANSI colors | +| `--dry-run` | switch | no | Dry run mode | +| `--non-interactive` | switch | no | Disable interactive prompts | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--console-switch-agent ` | number | no | Switch agent UID for delegated access | +| `--help` | switch | no | Show help | +| `--version` | switch | no | Print version | ## Notes diff --git a/skills/bailian-cli/reference/mcp.md b/skills/bailian-cli/reference/mcp.md index e879e36..c2a31d8 100644 --- a/skills/bailian-cli/reference/mcp.md +++ b/skills/bailian-cli/reference/mcp.md @@ -17,34 +17,34 @@ Index: [index.md](index.md) ### `bl mcp call` -| Field | Value | -| --------------- | --------------------------------------------------------------------------------- | -| **Name** | `mcp call` | -| **Description** | Call a tool on an MCP server (tools/call) | -| **Usage** | `bl mcp call . [--arg k=v ...] [--json '{...}'] [--url ]` | +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------- | +| **Name** | `mcp call` | +| **Description** | Call a tool on an MCP server (tools/call) | +| **Usage** | `bl mcp call --target [--arg k=v ...] [--json '{...}'] [--url ]` | #### Options -| Flag | Type | Required | Description | -| ---------------------- | ------ | -------- | ---------------------------------------------------------------------------------------- | -| `.` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection | -| `--arg ` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. | -| `--json ` | string | no | Full arguments object as JSON; merged with --arg (arg wins). | -| `--query ` | string | no | Shortcut for --arg query= (mirrors many DashScope MCP tools). | -| `--url ` | string | no | Override the MCP endpoint URL (for non-Bailian servers) | +| Flag | Type | Required | Description | +| ------------------------ | ------ | -------- | ---------------------------------------------------------------------------------------- | +| `--target ` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection | +| `--arg ` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. | +| `--json ` | string | no | Full arguments object as JSON; merged with --arg (arg wins). | +| `--query ` | string | no | Shortcut for --arg query= (mirrors many DashScope MCP tools). | +| `--url ` | string | no | Override the MCP endpoint URL (for non-Bailian servers) | #### Examples ```bash -bl mcp call market-cmapi00073529.SmartStockSelection --query "Screen consumer stocks with ROE > 15%" +bl mcp call --target market-cmapi00073529.SmartStockSelection --query "Screen consumer stocks with ROE > 15%" ``` ```bash -bl mcp call market-cmapi00073529.FinQuery --json '{"q":"Guizhou Maotai","limit":5}' +bl mcp call --target market-cmapi00073529.FinQuery --json '{"q":"Guizhou Maotai","limit":5}' ``` ```bash -bl mcp call market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10 +bl mcp call --target market-cmapi00073529.SmartFundSelection --arg riskLevel=R3 --arg minScale=10 ``` ### `bl mcp list` @@ -87,25 +87,25 @@ bl mcp list --output json | --------------- | ------------------------------------------------ | | **Name** | `mcp tools` | | **Description** | List tools exposed by an MCP server (tools/list) | -| **Usage** | `bl mcp tools [--url ]` | +| **Usage** | `bl mcp tools --server [--url ]` | #### Options -| Flag | Type | Required | Description | -| --------------- | ------ | -------- | ------------------------------------------------------- | -| `` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) | -| `--url ` | string | no | Override the MCP endpoint URL (for non-Bailian servers) | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------------------------------- | +| `--server ` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) | +| `--url ` | string | no | Override the MCP endpoint URL (for non-Bailian servers) | #### Examples ```bash -bl mcp tools market-cmapi00073529 +bl mcp tools --server market-cmapi00073529 ``` ```bash -bl mcp tools market-cmapi00073529 --output json +bl mcp tools --server market-cmapi00073529 --output json ``` ```bash -bl mcp tools my-server --url https://example.com/mcp +bl mcp tools --server my-server --url https://example.com/mcp ``` diff --git a/skills/bailian-cli/reference/pipeline.md b/skills/bailian-cli/reference/pipeline.md index b81b52a..285486e 100644 --- a/skills/bailian-cli/reference/pipeline.md +++ b/skills/bailian-cli/reference/pipeline.md @@ -16,42 +16,43 @@ Index: [index.md](index.md) ### `bl pipeline run` -| Field | Value | -| --------------- | ---------------------------------- | -| **Name** | `pipeline run` | -| **Description** | Run a pipeline workflow definition | -| **Usage** | `bl pipeline run [flags]` | +| Field | Value | +| --------------- | --------------------------------------- | +| **Name** | `pipeline run` | +| **Description** | Run a pipeline workflow definition | +| **Usage** | `bl pipeline run --file [flags]` | #### Options -| Flag | Type | Required | Description | -| --------------------- | ------ | -------- | ------------------------------- | -| `--input ` | string | no | Runtime input as inline JSON | -| `--input-file ` | string | no | Runtime input from a JSON file | -| `--concurrency ` | number | no | Max parallel steps (default: 1) | -| `--events ` | string | no | Emit lifecycle events: jsonl | -| `--timeout ` | number | no | Default step timeout in seconds | +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------ | +| `--file ` | string | yes | Pipeline definition file (YAML/JSON) | +| `--input ` | string | no | Runtime input as inline JSON | +| `--input-file ` | string | no | Runtime input from a JSON file | +| `--concurrency ` | number | no | Max parallel steps (default: 1) | +| `--events ` | string | no | Emit lifecycle events: jsonl | +| `--timeout ` | number | no | Default step timeout in seconds | #### Examples ```bash -bl pipeline run workflow.yaml --input '{"brief":"hello"}' +bl pipeline run --file workflow.yaml --input '{"brief":"hello"}' ``` ```bash -bl pipeline run workflow.json --input-file inputs.json --concurrency 3 +bl pipeline run --file workflow.json --input-file inputs.json --concurrency 3 ``` ```bash -bl pipeline run workflow.yaml --dry-run +bl pipeline run --file workflow.yaml --dry-run ``` ```bash -bl pipeline run workflow.json --events jsonl +bl pipeline run --file workflow.json --events jsonl ``` ```bash -bl pipeline run workflow.yaml --output json +bl pipeline run --file workflow.yaml --output json ``` ### `bl pipeline validate` @@ -60,18 +61,20 @@ bl pipeline run workflow.yaml --output json | --------------- | ------------------------------------------------ | | **Name** | `pipeline validate` | | **Description** | Validate a pipeline definition without executing | -| **Usage** | `bl pipeline validate ` | +| **Usage** | `bl pipeline validate --file ` | #### Options -_No command-specific options._ +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | ------------------------------------ | +| `--file ` | string | yes | Pipeline definition file (YAML/JSON) | #### Examples ```bash -bl pipeline validate workflow.yaml +bl pipeline validate --file workflow.yaml ``` ```bash -bl pipeline validate workflow.json --output json +bl pipeline validate --file workflow.json --output json ``` diff --git a/skills/bailian-cli/reference/search.md b/skills/bailian-cli/reference/search.md index caf031d..b7766ff 100644 --- a/skills/bailian-cli/reference/search.md +++ b/skills/bailian-cli/reference/search.md @@ -25,7 +25,7 @@ Index: [index.md](index.md) | Flag | Type | Required | Description | | ---------------- | ------- | -------- | -------------------------------------- | -| `--query ` | string | yes | Search query text | +| `--query ` | string | no | Search query text | | `--count ` | number | no | Number of search results (default: 10) | | `--list-tools` | boolean | no | List available MCP tools and exit | diff --git a/skills/bailian-cli/reference/speech.md b/skills/bailian-cli/reference/speech.md index cf5bbe1..ea7c37f 100644 --- a/skills/bailian-cli/reference/speech.md +++ b/skills/bailian-cli/reference/speech.md @@ -79,7 +79,7 @@ bl speech recognize --url https://example.com/audio.mp3 --no-wait --quiet | Flag | Type | Required | Description | | ---------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | -| `--text ` | string | yes | Text to synthesize into speech | +| `--text ` | string | no | Text to synthesize into speech (or use --text-file) | | `--text-file ` | string | no | Read text from a file instead of --text | | `--model ` | string | no | Model ID (default: cosyvoice-v3-flash). System voices available for cosyvoice-v3-flash | | `--voice ` | string | no | Voice ID. Use --list-voices to see system voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID | diff --git a/skills/bailian-cli/reference/text.md b/skills/bailian-cli/reference/text.md index cf04650..cd125a4 100644 --- a/skills/bailian-cli/reference/text.md +++ b/skills/bailian-cli/reference/text.md @@ -23,19 +23,19 @@ Index: [index.md](index.md) #### Options -| Flag | Type | Required | Description | -| ------------------------ | ------- | -------- | ----------------------------------------------------- | -| `--model ` | string | no | Model ID (default: qwen3.7-max) | -| `--message ` | array | yes | Message text (repeatable, prefix role: to set role) | -| `--messages-file ` | string | no | JSON file with messages array (use - for stdin) | -| `--system ` | string | no | System prompt | -| `--max-tokens ` | number | no | Maximum tokens to generate (default: 4096) | -| `--temperature ` | number | no | Sampling temperature (0.0, 2.0] | -| `--top-p ` | number | no | Nucleus sampling threshold | -| `--stream` | boolean | no | Stream response tokens (default: on in TTY) | -| `--tool ` | array | no | Tool definition as JSON or file path (repeatable) | -| `--enable-thinking` | boolean | no | Enable thinking/reasoning mode (for qwen3/qwq models) | -| `--thinking-budget ` | number | no | Max tokens for thinking (default: 4096) | +| Flag | Type | Required | Description | +| ------------------------ | ------- | -------- | --------------------------------------------------------------------------- | +| `--model ` | string | no | Model ID (default: qwen3.7-max) | +| `--message ` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file | +| `--messages-file ` | string | no | JSON file with messages array (use - for stdin) | +| `--system ` | string | no | System prompt | +| `--max-tokens ` | number | no | Maximum tokens to generate (default: 4096) | +| `--temperature ` | number | no | Sampling temperature (0.0, 2.0] | +| `--top-p ` | number | no | Nucleus sampling threshold | +| `--stream` | boolean | no | Stream response tokens (default: on in TTY) | +| `--tool ` | array | no | Tool definition as JSON or file path (repeatable) | +| `--enable-thinking` | boolean | no | Enable thinking/reasoning mode (for qwen3/qwq models) | +| `--thinking-budget ` | number | no | Max tokens for thinking (default: 4096) | #### Examples diff --git a/skills/bailian-cli/reference/video.md b/skills/bailian-cli/reference/video.md index 54b088e..2b94cec 100644 --- a/skills/bailian-cli/reference/video.md +++ b/skills/bailian-cli/reference/video.md @@ -29,8 +29,8 @@ Index: [index.md](index.md) | Flag | Type | Required | Description | | ---------------- | ------ | -------- | ------------------------ | -| `--task-id ` | string | no | Task ID to download from | -| `--out ` | string | no | Output file path | +| `--task-id ` | string | yes | Task ID to download from | +| `--out ` | string | yes | Output file path | #### Examples @@ -63,8 +63,8 @@ bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet | `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) | | `--duration ` | number | no | Output video duration in seconds (2-10) | | `--audio-setting ` | string | no | Audio: auto (default) or origin (keep original) | -| `--prompt-extend ` | string | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | | `--seed ` | number | no | Random seed for reproducible generation | | `--download ` | string | no | Save video to file on completion | | `--no-wait` | boolean | no | Return task ID immediately without waiting | @@ -108,8 +108,8 @@ bl video edit --video https://example.com/input.mp4 --prompt "Put clothes on the | `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | | `--ratio ` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) | | `--duration ` | number | no | Video duration in seconds (default: 5) | -| `--prompt-extend ` | string | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | | `--seed ` | number | no | Random seed for reproducible generation | | `--download ` | string | no | Save video to file on completion | | `--no-wait` | boolean | no | Return task ID immediately without waiting | @@ -159,8 +159,8 @@ bl video generate --prompt "A cat playing with a ball" --watermark false | `--resolution ` | string | no | Resolution: 720P or 1080P (default: 1080P) | | `--ratio ` | string | no | Aspect ratio (16:9, 9:16, 1:1) | | `--duration ` | number | no | Video duration in seconds (default: 5) | -| `--prompt-extend ` | string | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | -| `--watermark ` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). | +| `--prompt-extend ` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). | +| `--watermark ` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). | | `--seed ` | number | no | Random seed for reproducible generation | | `--download ` | string | no | Save video to file on completion | | `--no-wait` | boolean | no | Return task ID immediately without waiting | @@ -201,7 +201,7 @@ bl video ref --prompt "Image 1 drinks water" --image person.jpg --watermark fals | Flag | Type | Required | Description | | ---------------- | ------ | -------- | ------------- | -| `--task-id ` | string | no | Async task ID | +| `--task-id ` | string | yes | Async task ID | #### Examples