refactor(runtime): resolve/middleware kernel + declarative arg validation

把 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 适配新行为
This commit is contained in:
若麒
2026-06-26 16:50:12 +08:00
parent 7a0a083b2e
commit 91e6c6f553
69 changed files with 845 additions and 1257 deletions
-95
View File
@@ -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 <text>", description: "Image description", required: true },
{ flag: "--model <model>", description: "Model ID" },
{ flag: "--watermark <bool>", 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");
});
+3 -3
View File
@@ -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 () => {
+2 -2
View File
@@ -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);
});
});
+17 -9
View File
@@ -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 <server-code> --dry-run 输出 /api/v1/mcps/<code>/mcp 形态 URL", async () => {
test("mcp tools --server <code> --dry-run 输出 /api/v1/mcps/<code>/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 <server-code>.<tool> --dry-run 输出工具调用计划", async () => {
test("mcp call --target <server.tool> --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",
+16 -6
View File
@@ -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 <file>/i);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/Usage: bl pipeline run --file <path>|--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",
+13 -15
View File
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
-109
View File
@@ -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<string, unknown>)[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<string, unknown>)[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);
});
-42
View File
@@ -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();
});
@@ -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: "<prompt> [flags]",
usageArgs: "--message <text> [flags]",
options: [
{
flag: "--message <text>",
description: "Describe your requirements (alternative to positional prompt)",
},
{
flag: "--dry-run",
description: "Show intent analysis and candidate list without LLM ranking",
},
{
flag: "--output <format>",
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<string, unknown>)._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);
@@ -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 <id> --prompt <text>"));
const prompt = flags.prompt as string;
if (!prompt) failIfMissing("prompt", cmdUsage(config, "--app-id <id> --prompt <text>"));
const shouldStream =
flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
+3 -19
View File
@@ -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;
+5 -12
View File
@@ -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 <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 <value>", description: "Value to set" },
{ flag: "--value <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 <key> --value <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;
@@ -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 <api> --data <json>"));
const dataRaw = flags.data as string;
if (!dataRaw) failIfMissing("data", cmdUsage(config, "--api <api> --data <json>"));
let data: Record<string, unknown>;
try {
+4 -12
View File
@@ -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 <path> --model <model>"));
}
const model = flags.model as string | undefined;
if (!model) {
failIfMissing("model", cmdUsage(config, "--file <path> --model <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) {
+3 -21
View File
@@ -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 <bool>",
description: BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
type: "boolean",
},
{
flag: "--watermark <bool>",
description: BOOL_FLAG_WATERMARK,
type: "boolean",
},
{ flag: "--out-dir <dir>", description: "Download images to directory" },
{ flag: "--out-prefix <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 <url> --prompt <text>"));
}
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 <url> --prompt <text>"));
}
}
const prompt = flags.prompt as string;
const model = (flags.model as string) || config.defaultImageModel || "qwen-image-2.0";
@@ -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 <bool>",
description: BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE,
type: "boolean",
},
{
flag: "--watermark <bool>",
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 <text>"));
}
}
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
@@ -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 <id> --query <text>"));
const query = flags.query as string;
if (!query) failIfMissing("query", cmdUsage(config, "--index-id <id> --query <text>"));
const format = detectOutputFormat(config.output);
+10 -14
View File
@@ -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<string, unknown> {
export default defineCommand({
description: "Call a tool on an MCP server (tools/call)",
auth: "apiKey",
usageArgs: "<server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
usageArgs: "--target <server.tool> [--arg k=v ...] [--json '{...}'] [--url <url>]",
options: [
{
flag: "<server-code>.<tool>",
flag: "--target <server.tool>",
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 <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<string, unknown>)._positional as string[] | undefined) ?? [];
const target = positional[0];
if (!target) failIfMissing("<server-code>.<tool>", cmdUsage(config, "<server-code>.<tool>"));
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 <server-code>.<tool>, 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<string, unknown> = {};
if (flags.json) {
+7 -11
View File
@@ -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: "<server-code> [--url <url>]",
usageArgs: "--server <code> [--url <url>]",
options: [
{
flag: "<server-code>",
flag: "--server <code>",
description: "Server code from `mcp list` (e.g. market-cmapi00073529)",
required: true,
},
{ flag: "--url <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<string, unknown>)._positional as string[] | undefined) ?? [];
const code = positional[0];
if (!code) failIfMissing("server-code", cmdUsage(config, "<server-code>"));
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) {
+1 -7
View File
@@ -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 <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;
@@ -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 <id> --user-id <id>"));
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", cmdUsage(config, "--node-id <id> --user-id <id>"));
const format = detectOutputFormat(config.output);
const params = new URLSearchParams({ user_id: userId });
@@ -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 <id>"));
const format = detectOutputFormat(config.output);
const params = new URLSearchParams();
@@ -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 <name> --attributes <json>"));
const attrStr = flags.attributes as string;
if (!attrStr)
failIfMissing("attributes", cmdUsage(config, "--name <name> --attributes <json>"));
let attributes;
try {
@@ -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 <id> --user-id <id>"));
const userId = flags.userId as string;
if (!userId) failIfMissing("user-id", cmdUsage(config, "--schema-id <id> --user-id <id>"));
const format = detectOutputFormat(config.output);
const params = new URLSearchParams({ user_id: userId });
@@ -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 <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;
@@ -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 <id> --user-id <id> --content <text>"));
const userId = flags.userId as string;
if (!userId)
failIfMissing("user-id", cmdUsage(config, "--node-id <id> --user-id <id> --content <text>"));
const content = flags.content as string;
if (!content)
failIfMissing("content", cmdUsage(config, "--node-id <id> --user-id <id> --content <text>"));
const body: MemoryNodeUpdateRequest = {
user_id: userId,
+1 -19
View File
@@ -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 <text>"));
}
}
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";
+9 -14
View File
@@ -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: "<file> [flags]",
usageArgs: "--file <path> [flags]",
options: [
{ flag: "--file <path>", description: "Pipeline definition file (YAML/JSON)", required: true },
{ flag: "--input <json>", description: "Runtime input as inline JSON" },
{ flag: "--input-file <path>", 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, "<file>")}\n`,
);
process.exit(2);
}
const file = flags.file as string;
initPipelineSteps();
@@ -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: "<file>",
options: [],
exampleArgs: ["workflow.yaml", "workflow.json --output json"],
usageArgs: "--file <path>",
options: [
{ flag: "--file <path>", 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, "<file>")}\n`,
);
process.exit(2);
}
const file = flags.file as string;
initPipelineSteps();
+3 -16
View File
@@ -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 <text> [flags]",
options: [
{ flag: "--query <text>", description: "Search query text", required: true },
{ flag: "--query <text>", description: "Search query text" },
{ flag: "--count <n>", 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 <text>"));
}
}
const query = flags.query as string;
if (config.dryRun) {
emitResult(
@@ -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 <audio-url>"));
}
// Strict validation: --speaker-count requires --diarization
const speakerCount = flags.speakerCount as number | undefined;
@@ -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 <text> [flags]",
options: [
{ flag: "--text <text>", description: "Text to synthesize into speech", required: true },
{ flag: "--text <text>", description: "Text to synthesize into speech (or use --text-file)" },
{ flag: "--text-file <path>", description: "Read text from a file instead of --text" },
{
flag: "--model <model>",
@@ -193,6 +192,12 @@ export default defineCommand({
"# Pipe to ffplay",
'--text "Hello" --voice <voice_id> --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 <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 <voice_id>.\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;
+4 -21
View File
@@ -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 <model>", description: "Model ID (default: qwen3.7-max)" },
{
flag: "--message <text>",
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 <text>"));
}
}
const { system, messages } = parseMessages(flags);
const model = (flags.model as string) || config.defaultTextModel || "qwen3.7-max";
const shouldStream =
@@ -140,19 +140,13 @@ export default defineCommand({
"--off --model qwen3-max",
"--off --all",
],
validate: (f) =>
!f.model && !f.all ? "Provide --model <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 <model>[,model2,...] or --all\n",
);
process.exit(1);
}
let models: string[];
if (modelFlag) {
models = [
@@ -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 <id> --out <path>",
options: [
{ flag: "--task-id <id>", description: "Task ID to download from" },
{ flag: "--out <path>", description: "Output file path" },
{ flag: "--task-id <id>", description: "Task ID to download from", required: true },
{ flag: "--out <path>", 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 <id> --out <path>"));
const taskId = flags.taskId as string;
const outPath = flags.out as string | undefined;
if (!outPath) failIfMissing("out", cmdUsage(config, "--task-id <id> --out video.mp4"));
const outPath = flags.out as string;
const format = detectOutputFormat(config.output);
+5 -29
View File
@@ -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 <bool>",
description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
type: "boolean",
},
{
flag: "--watermark <bool>",
description: BOOL_FLAG_WATERMARK,
type: "boolean",
},
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{ flag: "--download <path>", 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 <url> --prompt <text>"));
}
}
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);
@@ -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 <bool>",
description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
type: "boolean",
},
{
flag: "--watermark <bool>",
description: BOOL_FLAG_WATERMARK,
type: "boolean",
},
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{ flag: "--download <path>", 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 <text>"));
}
}
const prompt = flags.prompt as string;
const model =
(flags.model as string) ||
+7 -26
View File
@@ -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 <bool>",
description: BOOL_FLAG_PROMPT_EXTEND_API_DEFAULT,
type: "boolean",
},
{
flag: "--watermark <bool>",
description: BOOL_FLAG_WATERMARK,
type: "boolean",
},
{ flag: "--seed <n>", description: "Random seed for reproducible generation", type: "number" },
{ flag: "--download <path>", 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 <text> --image <url>"));
}
}
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) || [];
@@ -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 <id>",
options: [{ flag: "--task-id <id>", description: "Async task ID" }],
options: [{ flag: "--task-id <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 <id>"));
const taskId = flags.taskId as string;
const format = detectOutputFormat(config.output);
@@ -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 <path-or-url>")}\n${cmdUsage(config, "--video <url-or-path>")}`,
);
}
}
const format = detectOutputFormat(config.output);
if (config.dryRun) {
+28
View File
@@ -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<string, unknown> | undefined {
if (cause == null) return undefined;
if (cause instanceof Error) {
+1 -1
View File
@@ -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";
-3
View File
@@ -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";
}
+30 -8
View File
@@ -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 <value>` → string (default).
* - number : `--flag <n>` → 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<void>;
}
@@ -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<void>;
}
@@ -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 <url>", description: "API base URL" },
{ flag: "--output <format>", description: "Output format: text, json" },
{ flag: "--timeout <seconds>", 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 <n>", description: "Run N parallel requests (default: 1)", type: "number" },
{
flag: "--console-region <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" },
];
-1
View File
@@ -8,7 +8,6 @@ export interface GlobalFlags {
noColor: boolean;
yes: boolean;
dryRun: boolean;
help: boolean;
nonInteractive: boolean;
async: boolean;
consoleRegion?: string;
+128 -116
View File
@@ -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<string>;
booleans: Set<string>;
numbers: Set<string>;
arrays: Set<string>;
@@ -34,152 +28,170 @@ function buildAllowedFlagKeys(options: OptionDef[]): Set<string> {
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<string>();
const booleans = new Set<string>();
const numbers = new Set<string>();
const arrays = new Set<string>();
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 <value> 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<string>();
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<string, unknown>)[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<string, unknown>)[camelKey] = true;
i++;
continue;
}
// --prompt <text>, --watermark <bool>, …
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<string, unknown>)[camelKey] as string[] | undefined;
if (arr) arr.push(value);
else (flags as Record<string, unknown>)[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<string, unknown>)[camelKey] = numericValue;
} else {
(flags as Record<string, unknown>)[camelKey] = value;
}
if (schema.arrays.has(camelKey)) {
const arr = (flags as Record<string, unknown>)[camelKey] as string[] | undefined;
if (arr) arr.push(value);
else (flags as Record<string, unknown>)[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<string, unknown>)[camelKey] = n;
} else if (schema.booleans.has(camelKey)) {
const v = value.trim().toLowerCase();
if (v === "true") (flags as Record<string, unknown>)[camelKey] = true;
else if (v === "false") (flags as Record<string, unknown>)[camelKey] = false;
else throw new UsageError(`Flag --${key} requires true or false.`);
} else {
(flags as Record<string, unknown>)[camelKey] = value;
}
}
const missing = options.filter((opt) => {
if (!opt.required) return false;
const key = flagKey(opt);
return key !== null && (flags as Record<string, unknown>)[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;
+83 -102
View File
@@ -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<string, Command>, 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<string, Command>, opts: CliOptions):
else throw e;
});
async function main(): Promise<void> {
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<void> {
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<string, unknown>)._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,
);
},
};
}
+7 -15
View File
@@ -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,
+83
View File
@@ -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<void>) => Promise<void>;
/** Fold a middleware list into a single runnable function. */
export function compose(stack: Middleware[]): (ctx: RunContext) => Promise<void> {
return (ctx) => {
const dispatch = (i: number): Promise<void> => {
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);
+6 -26
View File
@@ -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: `<binName> <path> <args>`.
* 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,
);
}
+49 -32
View File
@@ -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<string, CommandNode>;
}
/**
* 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, []) ?? "<resource> <command>";
}
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 {
+41
View File
@@ -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 };
}
}
@@ -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);
}
}
+111 -66
View File
@@ -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 <text>", description: "Image description", required: true },
{ flag: "--model <model>", description: "Model ID" },
{ flag: "--watermark <bool>", description: BOOL_FLAG_WATERMARK },
{ flag: "--no-wait", description: "Return task ID immediately without waiting" },
{ flag: "--image <url>", description: "Image URL (repeatable)", type: "array" },
{ flag: "--n <count>", description: "Number of images", type: "number" },
{ flag: "--watermark <bool>", 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");
});
+4 -10
View File
@@ -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 <prompt> [flags]` |
| **Usage** | `bl advisor recommend --message <text> [flags]` |
#### Options
| Flag | Type | Required | Description |
| ------------------- | ------- | -------- | ------------------------------------------------------------- |
| `--message <text>` | string | no | Describe your requirements (alternative to positional prompt) |
| `--dry-run` | boolean | no | Show intent analysis and candidate list without LLM ranking |
| `--output <format>` | string | no | Output format: text (default in TTY), json, yaml |
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | -------------------------- |
| `--message <text>` | 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
```
+2 -2
View File
@@ -26,8 +26,8 @@ Index: [index.md](index.md)
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `--key <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 <value>` | string | no | Value to set |
| `--key <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 <value>` | string | yes | Value to set |
#### Examples
+15 -15
View File
@@ -24,19 +24,19 @@ Index: [index.md](index.md)
#### Options
| Flag | Type | Required | Description |
| -------------------------- | ------ | -------- | ----------------------------------------------------------------------- |
| `--image <url>` | array | yes | Source image URL or local file path (repeatable for multi-image merge) |
| `--prompt <text>` | string | yes | Edit instruction text |
| `--model <model>` | string | no | Model ID (default: qwen-image-2.0) |
| `--size <W*H>` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) |
| `--n <count>` | number | no | Number of images (default: 1, max: 6) |
| `--seed <n>` | number | no | Random seed for reproducible results |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--prompt-extend <bool>` | string | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--out-dir <dir>` | string | no | Download images to directory |
| `--out-prefix <prefix>` | string | no | Filename prefix (default: edited) |
| Flag | Type | Required | Description |
| -------------------------- | ------- | -------- | ----------------------------------------------------------------------- |
| `--image <url>` | array | yes | Source image URL or local file path (repeatable for multi-image merge) |
| `--prompt <text>` | string | yes | Edit instruction text |
| `--model <model>` | string | no | Model ID (default: qwen-image-2.0) |
| `--size <W*H>` | string | no | Output image size: ratio (3:4, 16:9) or pixels (2048\*2048) |
| `--n <count>` | number | no | Number of images (default: 1, max: 6) |
| `--seed <n>` | number | no | Random seed for reproducible results |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to use CLI default (true). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--out-dir <dir>` | string | no | Download images to directory |
| `--out-prefix <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 <count>` | number | no | Number of images per request (default: 1, max: 6) |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--negative-prompt <text>` | string | no | Negative prompt to exclude unwanted content |
| `--prompt-extend <bool>` | string | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). |
| `--watermark <bool>` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag: true for qwen-image sync; parameter omitted on async models (API default). |
| `--watermark <bool>` | 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 <dir>` | string | no | Download images to directory |
| `--out-prefix <prefix>` | string | no | Filename prefix (default: image) |
+17 -17
View File
@@ -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 <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| `--output <format>` | string | no | Output format: text, json |
| `--timeout <seconds>` | 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 <n>` | number | no | Run N parallel requests (default: 1) |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--help` | boolean | no | Show help |
| `--version` | boolean | no | Print version |
| Flag | Type | Required | Description |
| ------------------------------ | ------ | -------- | -------------------------------------------------------- |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| `--output <format>` | string | no | Output format: text, json |
| `--timeout <seconds>` | 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 <n>` | number | no | Run N parallel requests (default: 1) |
| `--console-region <region>` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID for delegated access |
| `--help` | switch | no | Show help |
| `--version` | switch | no | Print version |
## Notes
+23 -23
View File
@@ -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 <server-code>.<tool> [--arg k=v ...] [--json '{...}'] [--url <url>]` |
| Field | Value |
| --------------- | ----------------------------------------------------------------------------------- |
| **Name** | `mcp call` |
| **Description** | Call a tool on an MCP server (tools/call) |
| **Usage** | `bl mcp call --target <server.tool> [--arg k=v ...] [--json '{...}'] [--url <url>]` |
#### Options
| Flag | Type | Required | Description |
| ---------------------- | ------ | -------- | ---------------------------------------------------------------------------------------- |
| `<server-code>.<tool>` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection |
| `--arg <kv>` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. |
| `--json <obj>` | string | no | Full arguments object as JSON; merged with --arg (arg wins). |
| `--query <text>` | string | no | Shortcut for --arg query=<text> (mirrors many DashScope MCP tools). |
| `--url <url>` | string | no | Override the MCP endpoint URL (for non-Bailian servers) |
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | ---------------------------------------------------------------------------------------- |
| `--target <server.tool>` | string | yes | Server code and tool name joined by a dot, e.g. market-cmapi00073529.SmartStockSelection |
| `--arg <kv>` | array | no | Tool argument (repeatable). Values parsed as JSON if possible, else string. |
| `--json <obj>` | string | no | Full arguments object as JSON; merged with --arg (arg wins). |
| `--query <text>` | string | no | Shortcut for --arg query=<text> (mirrors many DashScope MCP tools). |
| `--url <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 <server-code> [--url <url>]` |
| **Usage** | `bl mcp tools --server <code> [--url <url>]` |
#### Options
| Flag | Type | Required | Description |
| --------------- | ------ | -------- | ------------------------------------------------------- |
| `<server-code>` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) |
| `--url <url>` | string | no | Override the MCP endpoint URL (for non-Bailian servers) |
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------- |
| `--server <code>` | string | yes | Server code from `mcp list` (e.g. market-cmapi00073529) |
| `--url <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
```
+24 -21
View File
@@ -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 <file> [flags]` |
| Field | Value |
| --------------- | --------------------------------------- |
| **Name** | `pipeline run` |
| **Description** | Run a pipeline workflow definition |
| **Usage** | `bl pipeline run --file <path> [flags]` |
#### Options
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------- |
| `--input <json>` | string | no | Runtime input as inline JSON |
| `--input-file <path>` | string | no | Runtime input from a JSON file |
| `--concurrency <n>` | number | no | Max parallel steps (default: 1) |
| `--events <format>` | string | no | Emit lifecycle events: jsonl |
| `--timeout <seconds>` | number | no | Default step timeout in seconds |
| Flag | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------ |
| `--file <path>` | string | yes | Pipeline definition file (YAML/JSON) |
| `--input <json>` | string | no | Runtime input as inline JSON |
| `--input-file <path>` | string | no | Runtime input from a JSON file |
| `--concurrency <n>` | number | no | Max parallel steps (default: 1) |
| `--events <format>` | string | no | Emit lifecycle events: jsonl |
| `--timeout <seconds>` | 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 <file>` |
| **Usage** | `bl pipeline validate --file <path>` |
#### Options
_No command-specific options._
| Flag | Type | Required | Description |
| --------------- | ------ | -------- | ------------------------------------ |
| `--file <path>` | 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
```
+1 -1
View File
@@ -25,7 +25,7 @@ Index: [index.md](index.md)
| Flag | Type | Required | Description |
| ---------------- | ------- | -------- | -------------------------------------- |
| `--query <text>` | string | yes | Search query text |
| `--query <text>` | string | no | Search query text |
| `--count <n>` | number | no | Number of search results (default: 10) |
| `--list-tools` | boolean | no | List available MCP tools and exit |
+1 -1
View File
@@ -79,7 +79,7 @@ bl speech recognize --url https://example.com/audio.mp3 --no-wait --quiet
| Flag | Type | Required | Description |
| ---------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `--text <text>` | string | yes | Text to synthesize into speech |
| `--text <text>` | string | no | Text to synthesize into speech (or use --text-file) |
| `--text-file <path>` | string | no | Read text from a file instead of --text |
| `--model <model>` | string | no | Model ID (default: cosyvoice-v3-flash). System voices available for cosyvoice-v3-flash |
| `--voice <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 |
+13 -13
View File
@@ -23,19 +23,19 @@ Index: [index.md](index.md)
#### Options
| Flag | Type | Required | Description |
| ------------------------ | ------- | -------- | ----------------------------------------------------- |
| `--model <model>` | string | no | Model ID (default: qwen3.7-max) |
| `--message <text>` | array | yes | Message text (repeatable, prefix role: to set role) |
| `--messages-file <path>` | string | no | JSON file with messages array (use - for stdin) |
| `--system <text>` | string | no | System prompt |
| `--max-tokens <n>` | number | no | Maximum tokens to generate (default: 4096) |
| `--temperature <n>` | number | no | Sampling temperature (0.0, 2.0] |
| `--top-p <n>` | number | no | Nucleus sampling threshold |
| `--stream` | boolean | no | Stream response tokens (default: on in TTY) |
| `--tool <json-or-path>` | array | no | Tool definition as JSON or file path (repeatable) |
| `--enable-thinking` | boolean | no | Enable thinking/reasoning mode (for qwen3/qwq models) |
| `--thinking-budget <n>` | number | no | Max tokens for thinking (default: 4096) |
| Flag | Type | Required | Description |
| ------------------------ | ------- | -------- | --------------------------------------------------------------------------- |
| `--model <model>` | string | no | Model ID (default: qwen3.7-max) |
| `--message <text>` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file |
| `--messages-file <path>` | string | no | JSON file with messages array (use - for stdin) |
| `--system <text>` | string | no | System prompt |
| `--max-tokens <n>` | number | no | Maximum tokens to generate (default: 4096) |
| `--temperature <n>` | number | no | Sampling temperature (0.0, 2.0] |
| `--top-p <n>` | number | no | Nucleus sampling threshold |
| `--stream` | boolean | no | Stream response tokens (default: on in TTY) |
| `--tool <json-or-path>` | array | no | Tool definition as JSON or file path (repeatable) |
| `--enable-thinking` | boolean | no | Enable thinking/reasoning mode (for qwen3/qwq models) |
| `--thinking-budget <n>` | number | no | Max tokens for thinking (default: 4096) |
#### Examples
+9 -9
View File
@@ -29,8 +29,8 @@ Index: [index.md](index.md)
| Flag | Type | Required | Description |
| ---------------- | ------ | -------- | ------------------------ |
| `--task-id <id>` | string | no | Task ID to download from |
| `--out <path>` | string | no | Output file path |
| `--task-id <id>` | string | yes | Task ID to download from |
| `--out <path>` | string | yes | Output file path |
#### Examples
@@ -63,8 +63,8 @@ bl video download --task-id 3b256896-xxxx --out video.mp4 --quiet
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1, 4:3, 3:4) |
| `--duration <seconds>` | number | no | Output video duration in seconds (2-10) |
| `--audio-setting <mode>` | string | no | Audio: auto (default) or origin (keep original) |
| `--prompt-extend <bool>` | string | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | 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 <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (e.g. 16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | string | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | 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 <res>` | string | no | Resolution: 720P or 1080P (default: 1080P) |
| `--ratio <ratio>` | string | no | Aspect ratio (16:9, 9:16, 1:1) |
| `--duration <seconds>` | number | no | Video duration in seconds (default: 5) |
| `--prompt-extend <bool>` | string | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | string | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--prompt-extend <bool>` | boolean | no | Enable prompt extend (true/false). Omit flag to omit the parameter (DashScope default). |
| `--watermark <bool>` | boolean | no | Enable watermark (true/false). Omit flag to use CLI default (true). |
| `--seed <n>` | number | no | Random seed for reproducible generation |
| `--download <path>` | 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 <id>` | string | no | Async task ID |
| `--task-id <id>` | string | yes | Async task ID |
#### Examples