Merge branch 'main' into feat/model-usage-and-detail

This commit is contained in:
若麒
2026-07-13 16:23:19 +08:00
39 changed files with 1697 additions and 59 deletions
+5 -2
View File
@@ -7,7 +7,7 @@
monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"分层:
- `packages/core` — `bailian-cli-core`,纯逻辑层:鉴权、配置、HTTP client、错误、类型、文件工具
- `packages/runtime` — `bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline
- `packages/runtime` — `bailian-cli-runtime`,通用 CLI 运行时:`createCli`、参数解析、registry/help、middleware、error handler、输出、pipeline、Command Pack host
- `packages/commands` — `bailian-cli-commands`,可复用命令实现库,只导出 command,不决定产品路径
- `packages/cli` — `bailian-cli`,完整 `bl` 产品入口;`src/commands.ts` 组装 `bl` 暴露的命令路径
- `packages/kscli` — `knowledge-studio-cli`,Knowledge Studio 专用入口;`src/main.ts` 复用 commands 并重映射为 `kscli` 路径
@@ -17,14 +17,16 @@ monorepo 现在按"纯逻辑 → 运行时框架 → 命令库 → 产品入口"
```
packages/cli/src/main.ts # bl 入口,注入 binName/version/clientName/npmPackage
packages/cli/src/commands.ts # bl 产品命令 map,tools/generate-reference.ts 也读它
packages/cli/src/command-pack-policy.ts # bl 的 Command Pack policy
packages/kscli/src/main.ts # kscli 入口和命令 map
packages/commands/src/index.ts # re-export 单个命令实现
packages/commands/src/commands/ # defineCommand({ auth, flags, usageArgs, exampleArgs, run })
packages/runtime/src/create-cli.ts # createCli(commands, identity)
packages/runtime/src/create-cli.ts # createCli(commands, options)
packages/runtime/src/registry.ts # 命令树解析 + 动态 help
packages/runtime/src/middleware.ts # auth / telemetry / update / run command
packages/runtime/src/command-packs/ # 通用 Command Pack 加载、校验、隔离安装目录和管理命令
packages/runtime/src/urls.ts # 用户面控制台 URL
packages/core/src/types/command.ts # Command / flags / auth 类型
@@ -68,6 +70,7 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
+2 -2
View File
@@ -48,7 +48,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
- `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore()` / `ctx.configStore()` 的窄接口操作落盘。
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
## 必查清单
@@ -87,7 +87,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- [ ] `packages/commands/src/commands/auth/login.ts`:
- 新增/调整登录 flag 与流程
- 持久化只走 `ctx.authStore().login(...)`
- 持久化只走 `ctx.authStore.login(...)`
- [ ] `packages/commands/src/commands/auth/status.ts`:
- 分别显示 model / console / openapi 鉴权状态,并 mask token
- [ ] `packages/commands/src/commands/auth/logout.ts`:
+2 -1
View File
@@ -72,7 +72,8 @@ packages/commands/src/index.ts
- `exampleArgs`(不含 bin/path 前缀)
- `validate`(跨 flag 校验)
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
- `commands/auth/**` 可用 `ctx.authStore()`,`commands/config/**` 可用 `ctx.configStore()`;不要把这些 store accessor 扩散到普通业务命令
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
- [ ] `packages/commands/src/index.ts`:新增或移除对应 export
- [ ] 如果命令调用 Console Gateway,设置 `auth: "console"`;不要重复声明 console 凭证域 flags
- [ ] 如果命令不需要网络或自己管理配置/登录,设置 `auth: "none"`;不要绕过 runtime auth stage
+56
View File
@@ -0,0 +1,56 @@
# Command Pack 维护
## 触发条件
- 新增或移除 Command Pack 包
- 调整包白名单、允许的命令前缀或协议字段
- 修改 `plugin install/link/list/remove`
- 修改 Command Pack 加载、隔离、兼容性或独立安装目录
## 分层边界
- `packages/core/src/types/command-pack.ts`:稳定的协议元数据和导出类型,不知道具体产品或白名单。
- `packages/runtime/src/command-packs/`:所有 CLI 共用的加载、校验、API 适配、产品隔离安装目录和 manager 实现。
- `packages/runtime/src/create-cli.ts`:始终接收静态 command map,按 `CliOptions.commandPacks` 统一合并 pack,并把已绑定产品 identity/policy 的 manager 注入 `ctx.commandPacks`。
- `packages/commands/src/commands/plugin/`:普通共享管理命令,只依赖 `ctx.commandPacks`,不 import 任何产品 policy。
- `packages/cli/src/command-pack-policy.ts`:`bl` 支持的包、命令前缀和凭据授权。
- `kscli` 当前不传 `commandPacks`,使用 runtime 的默认空 policy。
- 当前只有 `bl` 从 `bailian-cli-commands` 导入并登记 `plugin *`;使用默认空 policy 的产品不提前暴露管理命令。
不要把产品白名单写进 core/runtime,也不要通过扫描全局 `node_modules` 自动发现包。通用机制放 runtime,产品差异只由 policy 表达。
## 安全与兼容性清单
- [ ] 包名必须精确命中当前产品 policy 的 `supported`,命令路径必须位于该包允许的前缀。
- [ ] 正式安装只接受包名加 version/tag;本地目录只走 `plugin link`。
- [ ] npm 使用独立安装目录和 `--ignore-scripts`,不污染 CLI 自身依赖树。
- [ ] npm 子进程只继承明确允许的 registry/config/cache/proxy/TLS 配置,不通配透传 pnpm 注入的 `npm_config_*`。
- [ ] 安装目录按 `identity.npmPackage` 隔离,不能让一个产品安装/删除另一个产品的 pack。
- [ ] 安装目录只隔离依赖位置,不隔离执行权限;Command Pack 必须视为 CLI 进程内的完全可信代码。
- [ ] 入口 realpath 不能逃逸包根目录。
- [ ] 加载前检查 `type`、`apiVersion`、`minCliVersion`;报告状态只使用 `loaded/failed`,具体原因写入 `error`。
- [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。
- [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。
- [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`。
- [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore` 或 `configStore`。
- [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager,避免 pack 安装或删除其他 pack。
- [ ] 单包失败必须 fail-open:保留内置命令和其他合法 pack。
- [ ] 破坏协议前优先在适配层兼容;确实无法兼容时才提升 `apiVersion`。
## 测试与文档
- [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。
- [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。
- [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。
- [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
- [ ] 更新生成的 `skills/bailian-cli/reference/plugin.md`;公开 `README.md` / `README.zh.md` 等正式对外发布时再补。
验证:
```sh
vp test packages/runtime/tests/command-packs.test.ts
vp test packages/cli/tests/e2e/command-packs.e2e.test.ts
vp test packages/kscli/tests/e2e/command-packs.e2e.test.ts
pnpm run sync:skill-assets
vp check
```
+11
View File
@@ -0,0 +1,11 @@
import type { CommandPackPolicy } from "bailian-cli-runtime";
/** Command Packs accepted by the bl product. */
export const commandPackPolicy = {
supported: {
"@ali/bailian-plugin-agent": {
commandPrefixes: ["agent"],
credentialAccess: ["apiKey"],
},
},
} as const satisfies CommandPackPolicy;
+8
View File
@@ -79,6 +79,10 @@ import {
tokenPlanCreateKey,
tokenPlanAssignSeats,
tokenPlanAddMember,
pluginInstall,
pluginLink,
pluginList,
pluginRemove,
} from "bailian-cli-commands";
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -166,4 +170,8 @@ export const commands: Record<string, AnyCommand> = {
"token-plan create-key": tokenPlanCreateKey,
"token-plan assign-seats": tokenPlanAssignSeats,
"token-plan add-member": tokenPlanAddMember,
"plugin install": pluginInstall,
"plugin link": pluginLink,
"plugin list": pluginList,
"plugin remove": pluginRemove,
};
+2
View File
@@ -1,5 +1,6 @@
import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
const quickStartTasks = [
@@ -15,4 +16,5 @@ void createCli(commands, {
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
@@ -0,0 +1,134 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test";
import { parseStdoutJson, runCli } from "./helpers.ts";
const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures", "command-pack");
let configDir: string;
function env(): NodeJS.ProcessEnv {
return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" };
}
describe("e2e: Command Pack", () => {
beforeAll(async () => {
configDir = await mkdtemp(join(tmpdir(), "bl-command-pack-e2e-"));
});
afterAll(async () => {
await rm(configDir, { recursive: true, force: true });
});
test("plugin 分组和管理命令 help 正常", async () => {
const group = await runCli(["plugin"], env());
expect(group.exitCode, group.stderr).toBe(0);
expect(group.stderr).toContain("plugin install");
expect(group.stderr).toContain("plugin link");
expect(group.stderr).toContain("plugin list");
expect(group.stderr).toContain("plugin remove");
const install = await runCli(["plugin", "install", "--help"], env());
expect(install.exitCode, install.stderr).toBe(0);
expect(install.stderr).toContain("--package");
});
test("未 link 时插件命令不存在", async () => {
const result = await runCli(["agent", "ping", "--message", "before"], env());
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/Unknown command/i);
});
test("link 后命令进入原生 registry/help/执行链路", async () => {
const linked = await runCli(
["plugin", "link", "--path", fixtureRoot, "--output", "json"],
env(),
);
expect(linked.exitCode, linked.stderr).toBe(0);
const linkedJson = parseStdoutJson<{ linked: { name: string; commands: string[] } }>(
linked.stdout,
);
expect(linkedJson.linked.name).toBe("@ali/bailian-plugin-agent");
expect(linkedJson.linked.commands).toEqual([
"agent credential",
"agent credential-denied",
"agent fail",
"agent output",
"agent ping",
]);
const rootHelp = await runCli(["--help"], env());
expect(rootHelp.exitCode, rootHelp.stderr).toBe(0);
expect(rootHelp.stderr).toContain("agent ping");
const commandHelp = await runCli(["agent", "ping", "--help"], env());
expect(commandHelp.exitCode, commandHelp.stderr).toBe(0);
expect(commandHelp.stderr).toContain("Ping the Command Pack fixture");
expect(commandHelp.stderr).toContain("--message");
const executed = await runCli(["agent", "ping", "--message", "hello"], env());
expect(executed.exitCode, executed.stderr).toBe(0);
expect(executed.stdout).toContain("command-pack:hello");
const credential = await runCli(["agent", "credential", "--api-key", "fixture-key"], env());
expect(credential.exitCode, credential.stderr).toBe(0);
expect(credential.stdout).toContain("credential-source:flag");
const denied = await runCli(["agent", "credential-denied"], {
...env(),
DASHSCOPE_API_KEY: "fixture-key",
});
expect(denied.exitCode).toBe(1);
expect(denied.stderr).toContain('must declare auth="apiKey"');
const outputText = await runCli(["agent", "output"], env());
expect(outputText.exitCode, outputText.stderr).toBe(0);
expect(outputText.stdout).toBe("command-pack-output\n");
const outputJson = await runCli(["agent", "output", "--output", "json"], env());
expect(outputJson.exitCode, outputJson.stderr).toBe(0);
expect(parseStdoutJson(outputJson.stdout)).toEqual({ source: "command-pack", ok: true });
const failed = await runCli(["agent", "fail", "--output", "text"], env());
expect(failed.exitCode).toBe(2);
expect(failed.stderr).toContain("Command Pack fixture usage error.");
expect(failed.stderr).toContain("Use agent fail only in tests.");
});
test("plugin list 输出加载状态", async () => {
const result = await runCli(["plugin", "list", "--output", "json"], env());
expect(result.exitCode, result.stderr).toBe(0);
const json = parseStdoutJson<{
command_packs: Array<{ name: string; status: string; commands: string[] }>;
}>(result.stdout);
expect(json.command_packs).toEqual([
expect.objectContaining({
name: "@ali/bailian-plugin-agent",
status: "loaded",
commands: [
"agent credential",
"agent credential-denied",
"agent fail",
"agent output",
"agent ping",
],
}),
]);
});
test("remove 后命令从下一进程消失", async () => {
const removed = await runCli(
["plugin", "remove", "--name", "@ali/bailian-plugin-agent", "--output", "json"],
env(),
);
expect(removed.exitCode, removed.stderr).toBe(0);
expect(parseStdoutJson<{ removed: string }>(removed.stdout).removed).toBe(
"@ali/bailian-plugin-agent",
);
const result = await runCli(["agent", "ping", "--message", "after"], env());
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/Unknown command/i);
});
});
+58
View File
@@ -0,0 +1,58 @@
const ping = {
description: "Ping the Command Pack fixture",
auth: "none",
flags: {
message: {
type: "string",
valueHint: "<text>",
required: true,
description: "Message returned by the fixture",
},
},
usageArgs: "--message <text>",
exampleArgs: ['--message "hello"'],
async run(ctx) {
process.stdout.write(`command-pack:${ctx.flags.message}\n`);
},
};
const credential = {
description: "Read an API key through the Command Pack host adapter",
auth: "apiKey",
async run(ctx) {
const apiKey = ctx.credentials.apiKey();
process.stdout.write(`credential-source:${apiKey.source}\n`);
},
};
const credentialDenied = {
description: "Verify credential access also requires command auth",
auth: "none",
async run(ctx) {
ctx.credentials.apiKey();
},
};
const output = {
description: "Exercise the Command Pack output helper",
auth: "none",
async run(ctx) {
ctx.output.result({ source: "command-pack", ok: true }, { text: "command-pack-output" });
},
};
const fail = {
description: "Exercise the Command Pack semantic error helper",
auth: "none",
async run(ctx) {
throw ctx.errors.usage("Command Pack fixture usage error.", "Use agent fail only in tests.");
},
};
export default {
"agent credential": credential,
"agent credential-denied": credentialDenied,
"agent fail": fail,
"agent output": output,
"agent ping": ping,
};
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@ali/bailian-plugin-agent",
"version": "0.0.0-test",
"private": true,
"type": "module",
"bailianCli": {
"type": "command-pack",
"apiVersion": 1,
"entry": "./commands.mjs",
"minCliVersion": "1.7.0"
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ export default defineCommand({
},
async run(ctx) {
const { identity, settings, flags } = ctx;
const store = ctx.authStore();
const store = ctx.authStore;
const deps = { identity, settings, authStore: store };
const key = flags.apiKey;
const baseUrl = flags.baseUrl || undefined;
@@ -20,7 +20,7 @@ export default defineCommand({
f.console && f.openApi ? "Use only one scope: --console or --open-api" : undefined,
async run(ctx) {
const { settings, flags } = ctx;
const store = ctx.authStore();
const store = ctx.authStore;
const stored = store.stored();
if (flags.console) {
@@ -9,7 +9,7 @@ export default defineCommand({
async run(ctx) {
const { identity, settings } = ctx;
const format = detectOutputFormat(settings.output);
const auth = ctx.authStore().describe();
const auth = ctx.authStore.describe();
const apiKey = auth.apiKey
? {
+1 -1
View File
@@ -106,7 +106,7 @@ export default defineCommand({
}
const coerced = resolvedKey === "timeout" ? Number(value) : value;
await ctx.configStore().write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
if (!settings.quiet) {
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
@@ -7,7 +7,7 @@ export default defineCommand({
exampleArgs: ["", "--output json"],
async run(ctx) {
const { settings, client } = ctx;
const store = ctx.configStore();
const store = ctx.configStore;
const file = store.read();
const format = detectOutputFormat(settings.output);
@@ -0,0 +1,21 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Install or upgrade an allowlisted Command Pack",
auth: "none",
usageArgs: "--package <name[@version]>",
flags: {
package: {
type: "string",
valueHint: "<name[@version]>",
description: "Allowlisted Command Pack package and optional version or tag",
required: true,
},
},
exampleArgs: ["--package @ali/bailian-plugin-agent", "--package @ali/bailian-plugin-agent@beta"],
async run(ctx) {
const installed = await ctx.commandPacks.install(ctx.flags.package);
emitResult({ installed }, detectOutputFormat(ctx.settings.output));
},
});
@@ -0,0 +1,21 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Link an allowlisted local Command Pack for development",
auth: "none",
usageArgs: "--path <directory>",
flags: {
path: {
type: "string",
valueHint: "<directory>",
description: "Local Command Pack package directory",
required: true,
},
},
exampleArgs: ["--path ../bailian-plugin-agent"],
async run(ctx) {
const linked = await ctx.commandPacks.link(ctx.flags.path);
emitResult({ linked }, detectOutputFormat(ctx.settings.output));
},
});
@@ -0,0 +1,33 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
export default defineCommand({
description: "List installed Command Packs and their load status",
auth: "none",
exampleArgs: ["", "--output json"],
async run(ctx) {
const reports = await ctx.commandPacks.list();
const format = detectOutputFormat(ctx.settings.output);
if (format === "json") {
emitResult({ command_packs: reports }, format);
return;
}
if (reports.length === 0) {
emitBare("No Command Packs installed.");
return;
}
const rows = reports.map((report) => [
report.name,
report.version ?? "-",
report.source,
report.status,
report.commands.join(", ") || report.error || "-",
]);
for (const line of formatTable(
["NAME", "VERSION", "SOURCE", "STATUS", "COMMANDS / ERROR"],
rows,
)) {
emitBare(line);
}
},
});
@@ -0,0 +1,21 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Remove an installed Command Pack",
auth: "none",
usageArgs: "--name <package>",
flags: {
name: {
type: "string",
valueHint: "<package>",
description: "Allowlisted Command Pack package name",
required: true,
},
},
exampleArgs: ["--name @ali/bailian-plugin-agent"],
async run(ctx) {
await ctx.commandPacks.remove(ctx.flags.name);
emitResult({ removed: ctx.flags.name }, detectOutputFormat(ctx.settings.output));
},
});
+4
View File
@@ -86,3 +86,7 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
export { default as pluginInstall } from "./commands/plugin/install.ts";
export { default as pluginLink } from "./commands/plugin/link.ts";
export { default as pluginList } from "./commands/plugin/list.ts";
export { default as pluginRemove } from "./commands/plugin/remove.ts";
@@ -1,32 +0,0 @@
import { readdirSync, readFileSync, statSync } from "fs";
import { join } from "path";
import { expect, test } from "vite-plus/test";
// 能力面边界(重构 §6 的 lint 规则):configStore() 仅 config 命令族、authStore()
// 仅 auth 命令族可用;业务命令只依赖 settings/flags/client。
const ROOT = join(import.meta.dirname, "../src/commands");
function walk(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (p.endsWith(".ts")) out.push(p);
}
return out;
}
test("configStore() 仅在 commands/config/** 使用", () => {
for (const file of walk(ROOT)) {
if (file.includes("/config/")) continue;
expect(readFileSync(file, "utf8").includes("configStore("), file).toBe(false);
}
});
test("authStore() 仅在 commands/auth/** 使用", () => {
for (const file of walk(ROOT)) {
if (file.includes("/auth/")) continue;
expect(readFileSync(file, "utf8").includes("authStore("), file).toBe(false);
}
});
@@ -0,0 +1,22 @@
export interface CommandPackMutationResult {
name: string;
version?: string;
commands: string[];
}
export interface CommandPackReport {
name: string;
version?: string;
source: "installed" | "linked";
status: "loaded" | "failed";
commands: string[];
error?: string;
}
/** Product-bound Command Pack management surface injected by the CLI runtime. */
export interface CommandPackManager {
install(spec: string): Promise<CommandPackMutationResult>;
link(path: string): Promise<CommandPackMutationResult>;
list(): Promise<CommandPackReport[]>;
remove(name: string): Promise<void>;
}
+72
View File
@@ -0,0 +1,72 @@
import type { ApiKeyCredential } from "../auth/types.ts";
import type { Client } from "../client/client.ts";
import type { Identity, Settings } from "../config/schema.ts";
import type { Command, FlagsDef, ParsedFlags } from "./command.ts";
/** Current Command Pack protocol version understood by this release. */
export const COMMAND_PACK_API_VERSION = 1 as const;
/** `package.json#bailianCli` metadata for a Command Pack package. */
export interface CommandPackMeta {
type: "command-pack";
apiVersion: number;
/** ESM entry path relative to the package root. */
entry: string;
/** Optional minimum compatible bailian-cli version. */
minCliVersion?: string;
}
/** Explicit credential delegation surface available only to trusted Command Packs. */
export interface CommandPackCredentials {
apiKey(): ApiKeyCredential;
}
export interface CommandPackOutputOptions {
/** Custom text-mode representation; JSON mode always serializes the primary data. */
text?: string;
}
/** Stable stdout surface supplied by the host. */
export interface CommandPackOutput {
result(data: unknown, options?: CommandPackOutputOptions): void;
line(value: string): void;
write(chunk: string): void;
}
export interface CommandPackErrorOptions {
hint?: string;
cause?: unknown;
}
/** Host-native semantic error factories; throw the returned Error. */
export interface CommandPackErrors {
general(message: string, options?: CommandPackErrorOptions): Error;
usage(message: string, hint?: string): Error;
timeout(message?: string, options?: CommandPackErrorOptions): Error;
}
/** Stable API 1 execution context. It deliberately contains no raw credentials. */
export interface CommandPackContext<F extends FlagsDef = FlagsDef> {
identity: Identity;
settings: Settings;
flags: ParsedFlags<F>;
client: Client;
output: CommandPackOutput;
errors: CommandPackErrors;
}
/** Privileged context available only when product policy grants raw API-key access. */
export type CommandPackApiKeyContext<F extends FlagsDef = FlagsDef> = CommandPackContext<F> & {
credentials: CommandPackCredentials;
};
/** Command shape exported by an API 1 Command Pack. */
export interface CommandPackCommand<
F extends FlagsDef = FlagsDef,
C extends CommandPackContext<F> = CommandPackContext<F>,
> extends Omit<Command<F>, "run"> {
run(ctx: C): Promise<void>;
}
/** A Command Pack explicitly maps product command paths to command implementations. */
export type CommandPack = Record<string, CommandPackCommand<any, any>>;
+7 -4
View File
@@ -2,6 +2,7 @@ import type { Identity, Settings } from "../config/schema.ts";
import type { ConfigStore } from "../config/store.ts";
import type { AuthStore } from "../auth/store.ts";
import type { Client } from "../client/client.ts";
import type { CommandPackManager } from "./command-pack-manager.ts";
// ── Flag definitions ─────────────────────────────────────────────────────────
// Flags are keyed by camelCase name (the key IS the parsed flag name, e.g.
@@ -164,10 +165,12 @@ export interface CommandContext<F extends FlagsDef = FlagsDef> {
flags: ParsedFlags<F>;
/** Network surface; the credential for the command's `auth` is pre-injected. */
client: Client;
/** 惰性访问器,lint 限定 commands/config/** 使用。 */
configStore(): ConfigStore;
/** 惰性访问器,lint 限定 commands/auth/** 使用。 */
authStore(): AuthStore;
/** 配置持久化能力;lint 限定 commands/config/** 使用。 */
configStore: ConfigStore;
/** 鉴权持久化能力;lint 限定 commands/auth/** 使用。 */
authStore: AuthStore;
/** Command Pack 管理能力;lint 限定 commands/plugin/** 使用。 */
commandPacks: CommandPackManager;
}
// ── Command ──────────────────────────────────────────────────────────────────
+18
View File
@@ -18,6 +18,24 @@ export {
CONSOLE_AUTH_FLAGS,
OPENAPI_AUTH_FLAGS,
} from "./command.ts";
export type {
CommandPack,
CommandPackApiKeyContext,
CommandPackCommand,
CommandPackContext,
CommandPackCredentials,
CommandPackErrorOptions,
CommandPackErrors,
CommandPackMeta,
CommandPackOutput,
CommandPackOutputOptions,
} from "./command-pack.ts";
export { COMMAND_PACK_API_VERSION } from "./command-pack.ts";
export type {
CommandPackManager,
CommandPackMutationResult,
CommandPackReport,
} from "./command-pack-manager.ts";
export type {
AppCompletionRequest,
AppCompletionResponse,
@@ -0,0 +1,14 @@
import { describe, expect, test } from "vite-plus/test";
import { runKscli } from "./helpers.ts";
describe("e2e: kscli Command Pack host", () => {
test("keeps the shared host inactive with the default empty policy", async () => {
const help = await runKscli(["--help"]);
expect(help.exitCode, help.stderr).toBe(0);
expect(help.stderr).not.toContain("plugin install");
const result = await runKscli(["plugin", "list"]);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toMatch(/Unknown command/i);
});
});
@@ -0,0 +1,83 @@
import type { AnyCommand, CommandPackReport, Identity } from "bailian-cli-core";
import { readCommandPackPackageJson, readCommandPacksManifest } from "./package-json.ts";
import { loadAndValidateCommandPack } from "./validate.ts";
import type { CommandPackPolicy } from "./types.ts";
export interface LoadedCommandPacks {
commands: Record<string, AnyCommand>;
reports: CommandPackReport[];
}
function sourceFromSpec(spec: string): "installed" | "linked" {
return spec.startsWith("file:") ? "linked" : "installed";
}
export async function loadCommandPacks(
builtins: Record<string, AnyCommand>,
identity: Identity,
policy: CommandPackPolicy,
): Promise<LoadedCommandPacks> {
const commandsWithPacks = { ...builtins };
const reports: CommandPackReport[] = [];
let manifest;
try {
manifest = await readCommandPacksManifest(identity);
} catch (error) {
return {
commands: commandsWithPacks,
reports: [
{
name: "(plugin sandbox)",
source: "installed",
status: "failed",
commands: [],
error: error instanceof Error ? error.message : String(error),
},
],
};
}
for (const [name, spec] of Object.entries(manifest.dependencies ?? {}).sort(([a], [b]) =>
a.localeCompare(b),
)) {
const source = sourceFromSpec(spec);
const definition = policy.supported[name];
if (!definition) {
reports.push({
name,
source,
status: "failed",
commands: [],
error: `Command Pack "${name}" is not supported by ${identity.binName}.`,
});
continue;
}
try {
const pjson = await readCommandPackPackageJson(identity, name);
const packCommands = await loadAndValidateCommandPack(name, pjson, definition, identity);
const conflicts = Object.keys(packCommands).filter((path) => path in commandsWithPacks);
if (conflicts.length > 0) {
throw new Error(`Command conflicts: ${conflicts.join(", ")}.`);
}
Object.assign(commandsWithPacks, packCommands);
reports.push({
name,
version: pjson.version,
source,
status: "loaded",
commands: Object.keys(packCommands).sort(),
});
} catch (error) {
reports.push({
name,
source,
status: "failed",
commands: [],
error: error instanceof Error ? error.message : String(error),
});
}
}
return { commands: commandsWithPacks, reports };
}
@@ -0,0 +1,353 @@
import { existsSync } from "node:fs";
import { mkdir, open, stat, unlink, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
import {
BailianError,
ExitCode,
type CommandPackManager,
type CommandPackReport,
type Identity,
} from "bailian-cli-core";
import { readCommandPackPackageJsonAt, readCommandPacksManifest } from "./package-json.ts";
import { getCommandPackRoot, getCommandPacksDir } from "./paths.ts";
import { loadCommandPacks } from "./load.ts";
import { loadAndValidateCommandPack } from "./validate.ts";
import type { CommandPackPolicy } from "./types.ts";
const SANDBOX_PACKAGE_JSON = {
name: "bailian-cli-command-packs",
private: true,
dependencies: {},
};
const LOCK_STALE_MS = 10 * 60 * 1000;
const NPM_ENV_ALLOW_EXACT = new Set([
"PATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"TMPDIR",
"TEMP",
"TMP",
"LANG",
"TERM",
"NODE",
"NODE_PATH",
"NODE_OPTIONS",
"FORCE_COLOR",
"NO_COLOR",
"NPM_TOKEN",
"NODE_AUTH_TOKEN",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"SystemRoot",
"ComSpec",
"APPDATA",
"PATHEXT",
]);
const NPM_CONFIG_ENV_ALLOW = new Set([
"registry",
"userconfig",
"globalconfig",
"cache",
"proxy",
"https_proxy",
"noproxy",
"strict_ssl",
"ca",
"cafile",
]);
function isAllowedNpmConfigEnv(key: string): boolean {
const match = /^(?:npm_config_|NPM_CONFIG_)(.+)$/.exec(key);
return !!match && NPM_CONFIG_ENV_ALLOW.has(match[1]!.toLowerCase());
}
function supportedPackageHint(identity: Identity, policy: CommandPackPolicy): string {
const names = Object.keys(policy.supported);
return names.length > 0
? `Allowed packages: ${names.join(", ")}`
: `${identity.binName} does not currently support any Command Packs.`;
}
function buildNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(base)) {
if (value === undefined) continue;
if (NPM_ENV_ALLOW_EXACT.has(key) || key.startsWith("LC_") || isAllowedNpmConfigEnv(key)) {
env[key] = value;
}
}
return env;
}
async function ensureSandboxAt(dir: string): Promise<void> {
await mkdir(dir, { recursive: true, mode: 0o700 });
const path = join(dir, "package.json");
if (!existsSync(path)) {
await writeFile(path, `${JSON.stringify(SANDBOX_PACKAGE_JSON, null, 2)}\n`, { mode: 0o600 });
}
}
async function runNpm(args: string[], cwd: string): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn("npm", args, {
cwd,
env: buildNpmEnv(),
stdio: ["inherit", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => process.stderr.write(chunk));
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
child.once("error", reject);
child.once("close", (code, signal) => {
if (code === 0) {
resolvePromise();
return;
}
reject(
new BailianError(
`npm ${args[0]} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}.`,
ExitCode.GENERAL,
),
);
});
});
}
async function acquireSandboxLock(identity: Identity): Promise<() => Promise<void>> {
const sandboxDir = getCommandPacksDir(identity);
await ensureSandboxAt(sandboxDir);
const path = join(sandboxDir, ".lock");
for (let attempt = 0; attempt < 2; attempt++) {
try {
const handle = await open(path, "wx", 0o600);
await handle.writeFile(`${process.pid} ${Date.now()}\n`);
await handle.close();
return async () => {
try {
await unlink(path);
} catch {
/* already released */
}
};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
try {
const info = await stat(path);
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
await unlink(path);
continue;
}
} catch {
continue;
}
throw new BailianError(
"Another Command Pack install, link, or remove operation is already running.",
ExitCode.GENERAL,
"Wait for it to finish and try again.",
);
}
}
throw new BailianError("Could not acquire the Command Pack operation lock.", ExitCode.GENERAL);
}
async function withSandboxLock<T>(identity: Identity, run: () => Promise<T>): Promise<T> {
const release = await acquireSandboxLock(identity);
try {
return await run();
} finally {
await release();
}
}
export function parseCommandPackSpec(
spec: string,
identity: Identity,
policy: CommandPackPolicy,
): { name: string; requested: string } {
const trimmed = spec.trim();
const match = /^(@[^/\s]+\/[^@/\s]+)(?:@([a-zA-Z0-9][a-zA-Z0-9._-]*))?$/.exec(trimmed);
if (!match) {
throw new BailianError(
`Unsupported Command Pack package spec: "${spec}".`,
ExitCode.USAGE,
"Use an allowlisted scoped package name with an optional version or tag.",
);
}
const name = match[1]!;
if (!policy.supported[name]) {
throw new BailianError(
`Command Pack "${name}" is not allowlisted for ${identity.binName}.`,
ExitCode.USAGE,
supportedPackageHint(identity, policy),
);
}
return { name, requested: trimmed };
}
async function validateAtRoot(
name: string,
root: string,
identity: Identity,
policy: CommandPackPolicy,
) {
const definition = policy.supported[name]!;
const pjson = await readCommandPackPackageJsonAt(root);
const commands = await loadAndValidateCommandPack(name, pjson, definition, identity, root);
return { pjson, commands };
}
function dependencyInstallSpec(name: string, spec: string): string {
return spec.startsWith("file:") ? spec : `${name}@${spec}`;
}
async function restoreCommandPack(
name: string,
previousSpec: string | undefined,
sandboxDir: string,
): Promise<void> {
if (previousSpec) {
await runNpm(
[
"install",
dependencyInstallSpec(name, previousSpec),
"--save-exact",
"--ignore-scripts",
"--no-fund",
"--no-audit",
],
sandboxDir,
);
return;
}
await runNpm(["uninstall", name, "--ignore-scripts", "--no-fund", "--no-audit"], sandboxDir);
}
export async function installCommandPack(
spec: string,
identity: Identity,
policy: CommandPackPolicy,
): Promise<{ name: string; version?: string; commands: string[] }> {
const { name, requested } = parseCommandPackSpec(spec, identity, policy);
return withSandboxLock(identity, async () => {
const sandboxDir = getCommandPacksDir(identity);
const manifest = await readCommandPacksManifest(identity);
const previousSpec = manifest.dependencies?.[name];
try {
await runNpm(
["install", requested, "--save-exact", "--ignore-scripts", "--no-fund", "--no-audit"],
sandboxDir,
);
const installed = await validateAtRoot(
name,
getCommandPackRoot(identity, name),
identity,
policy,
);
if (!installed.pjson.version) {
throw new BailianError(`Command Pack "${name}" has no package version.`, ExitCode.USAGE);
}
return {
name,
version: installed.pjson.version,
commands: Object.keys(installed.commands).sort(),
};
} catch (error) {
try {
await restoreCommandPack(name, previousSpec, sandboxDir);
} catch {
/* preserve the install or validation error */
}
throw error;
}
});
}
export async function linkCommandPack(
path: string,
identity: Identity,
policy: CommandPackPolicy,
): Promise<{ name: string; version?: string; commands: string[] }> {
const root = resolve(path);
if (!existsSync(join(root, "package.json"))) {
throw new BailianError(`Command Pack path does not exist: ${root}`, ExitCode.USAGE);
}
const pjson = await readCommandPackPackageJsonAt(root);
const name = pjson.name;
if (!name || !policy.supported[name]) {
throw new BailianError(
`Local package "${name ?? "(unnamed)"}" is not an allowlisted Command Pack for ${identity.binName}.`,
ExitCode.USAGE,
supportedPackageHint(identity, policy),
);
}
const checked = await validateAtRoot(name, root, identity, policy);
return withSandboxLock(identity, async () => {
await runNpm(
["install", `file:${root}`, "--save-exact", "--ignore-scripts", "--no-fund", "--no-audit"],
getCommandPacksDir(identity),
);
const installed = await validateAtRoot(
name,
getCommandPackRoot(identity, name),
identity,
policy,
);
return {
name,
version: installed.pjson.version ?? checked.pjson.version,
commands: Object.keys(installed.commands).sort(),
};
});
}
export async function removeCommandPack(
name: string,
identity: Identity,
policy: CommandPackPolicy,
): Promise<void> {
if (!policy.supported[name]) {
throw new BailianError(
`Command Pack "${name}" is not allowlisted for ${identity.binName}.`,
ExitCode.USAGE,
supportedPackageHint(identity, policy),
);
}
const manifest = await readCommandPacksManifest(identity);
if (!(name in (manifest.dependencies ?? {}))) {
throw new BailianError(`Command Pack "${name}" is not installed.`, ExitCode.USAGE);
}
await withSandboxLock(identity, () =>
runNpm(
["uninstall", name, "--ignore-scripts", "--no-fund", "--no-audit"],
getCommandPacksDir(identity),
),
);
}
export async function listCommandPacks(
identity: Identity,
policy: CommandPackPolicy,
): Promise<CommandPackReport[]> {
return (await loadCommandPacks({}, identity, policy)).reports;
}
export function createCommandPackManager(
identity: Identity,
policy: CommandPackPolicy,
): CommandPackManager {
return {
install: (spec) => installCommandPack(spec, identity, policy),
link: (path) => linkCommandPack(path, identity, policy),
list: () => listCommandPacks(identity, policy),
remove: (name) => removeCommandPack(name, identity, policy),
};
}
@@ -0,0 +1,40 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { BailianError, ExitCode, type Identity } from "bailian-cli-core";
import { getCommandPackRoot, getCommandPacksManifestPath } from "./paths.ts";
import type { CommandPackPackageJson, CommandPackSandboxManifest } from "./types.ts";
export async function readJson<T>(path: string): Promise<T> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch (error) {
throw new BailianError(
`Could not parse JSON file: ${path}`,
ExitCode.GENERAL,
"Repair or remove the invalid file and try again.",
{ cause: error },
);
}
}
export async function readCommandPacksManifest(
identity: Identity,
): Promise<CommandPackSandboxManifest> {
const path = getCommandPacksManifestPath(identity);
if (!existsSync(path)) return { dependencies: {} };
return readJson<CommandPackSandboxManifest>(path);
}
export async function readCommandPackPackageJson(
identity: Identity,
name: string,
): Promise<CommandPackPackageJson> {
return readCommandPackPackageJsonAt(getCommandPackRoot(identity, name));
}
export async function readCommandPackPackageJsonAt(
packageRoot: string,
): Promise<CommandPackPackageJson> {
return readJson<CommandPackPackageJson>(join(packageRoot, "package.json"));
}
@@ -0,0 +1,14 @@
import { join } from "node:path";
import { getConfigDir, type Identity } from "bailian-cli-core";
export function getCommandPacksDir(identity: Identity): string {
return join(getConfigDir(), "plugins", identity.npmPackage);
}
export function getCommandPacksManifestPath(identity: Identity): string {
return join(getCommandPacksDir(identity), "package.json");
}
export function getCommandPackRoot(identity: Identity, name: string): string {
return join(getCommandPacksDir(identity), "node_modules", ...name.split("/"));
}
@@ -0,0 +1,26 @@
import type { CommandPackMeta } from "bailian-cli-core";
export interface CommandPackDefinition {
commandPrefixes: readonly string[];
/** Raw credential domains explicitly delegated to this trusted package. */
credentialAccess?: readonly CommandPackCredentialAccess[];
}
export type CommandPackCredentialAccess = "apiKey";
/** Product policy: each CLI explicitly declares which Command Packs it accepts. */
export interface CommandPackPolicy {
supported: Readonly<Record<string, CommandPackDefinition>>;
}
export interface CommandPackPackageJson {
name?: string;
version?: string;
bailianCli?: CommandPackMeta;
}
export interface CommandPackSandboxManifest {
name?: string;
private?: boolean;
dependencies?: Record<string, string>;
}
@@ -0,0 +1,207 @@
import { realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
BailianError,
COMMAND_PACK_API_VERSION,
ExitCode,
UsageError,
formatOutput,
type AnyCommand,
type CommandPack,
type CommandPackCommand,
type CommandPackMeta,
type Identity,
} from "bailian-cli-core";
import { CommandRegistry } from "../registry.ts";
import { compareVersion } from "../utils/update-checker.ts";
import { getCommandPackRoot } from "./paths.ts";
import type { CommandPackDefinition, CommandPackPackageJson } from "./types.ts";
const AUTH_REQUIREMENTS = new Set(["none", "apiKey", "console", "openapi"]);
const COMMAND_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
function assertMeta(
name: string,
pjson: CommandPackPackageJson,
identity: Identity,
): CommandPackMeta {
if (pjson.name !== name) {
throw new Error(`Package name mismatch: expected "${name}", received "${pjson.name ?? ""}".`);
}
const meta = pjson.bailianCli;
if (!meta || meta.type !== "command-pack") {
throw new Error(`Package "${name}" is missing bailianCli.type="command-pack".`);
}
if (meta.apiVersion !== COMMAND_PACK_API_VERSION) {
throw new Error(
`Command Pack API ${meta.apiVersion} is not supported; this CLI supports API ${COMMAND_PACK_API_VERSION}.`,
);
}
if (meta.minCliVersion && compareVersion(identity.version, meta.minCliVersion) < 0) {
throw new Error(
`Command Pack requires ${identity.npmPackage} ${meta.minCliVersion} or newer; current version is ${identity.version}.`,
);
}
if (!meta.entry || isAbsolute(meta.entry)) {
throw new Error(`Command Pack "${name}" must declare a relative bailianCli.entry.`);
}
return meta;
}
function assertCommandPath(path: string, definition: CommandPackDefinition): void {
const normalized = path.split(/\s+/).filter(Boolean).join(" ");
if (!path || path !== normalized) {
throw new Error(`Invalid command path "${path}".`);
}
if (!path.split(" ").every((segment) => COMMAND_SEGMENT.test(segment))) {
throw new Error(`Invalid command path "${path}".`);
}
const allowed = definition.commandPrefixes.some(
(prefix) => path === prefix || path.startsWith(`${prefix} `),
);
if (!allowed) {
throw new Error(
`Command "${path}" is outside the allowed prefixes: ${definition.commandPrefixes.join(", ")}.`,
);
}
}
function assertCommand(path: string, value: unknown): asserts value is CommandPackCommand<any> {
if (!value || typeof value !== "object") {
throw new Error(`Command "${path}" must export an object.`);
}
const command = value as Partial<CommandPackCommand<any>>;
if (!command.description || typeof command.description !== "string") {
throw new Error(`Command "${path}" is missing a description.`);
}
if (!command.auth || !AUTH_REQUIREMENTS.has(command.auth)) {
throw new Error(`Command "${path}" has an invalid auth requirement.`);
}
if (typeof command.run !== "function") {
throw new Error(`Command "${path}" is missing run(ctx).`);
}
}
function adaptCommandPack(
name: string,
pack: CommandPack,
definition: CommandPackDefinition,
): Record<string, AnyCommand> {
return Object.fromEntries(
Object.entries(pack).map(([path, command]) => [
path,
{
description: command.description,
auth: command.auth,
usageArgs: command.usageArgs,
exampleArgs: command.exampleArgs,
notes: command.notes,
flags: command.flags,
validate: command.validate,
run: (ctx) => {
const packContext = {
identity: ctx.identity,
settings: ctx.settings,
flags: ctx.flags,
client: ctx.client,
output: {
result(data: unknown, options?: { text?: string }) {
const rendered =
ctx.settings.output === "text" && options?.text !== undefined
? options.text
: formatOutput(data, ctx.settings.output);
process.stdout.write(`${rendered}\n`);
},
line(value: string) {
process.stdout.write(`${value}\n`);
},
write(chunk: string) {
process.stdout.write(chunk);
},
},
errors: {
general(message: string, options?: { hint?: string; cause?: unknown }) {
return new BailianError(message, ExitCode.GENERAL, options?.hint, {
cause: options?.cause,
});
},
usage(message: string, hint?: string) {
return new UsageError(message, hint);
},
timeout(
message = "Request timed out.",
options?: { hint?: string; cause?: unknown },
) {
return new BailianError(message, ExitCode.TIMEOUT, options?.hint, {
cause: options?.cause,
});
},
},
credentials: {
apiKey() {
if (!definition.credentialAccess?.includes("apiKey")) {
throw new BailianError(
`Command Pack "${name}" is not allowed to access API-key credentials.`,
ExitCode.GENERAL,
);
}
if (command.auth !== "apiKey") {
throw new BailianError(
`Command "${path}" must declare auth="apiKey" before accessing API-key credentials.`,
ExitCode.GENERAL,
);
}
const credential = ctx.authStore.describe().apiKey;
if (!credential) {
throw new BailianError(
"No API key available to the Command Pack.",
ExitCode.AUTH,
);
}
return credential;
},
},
};
return command.run(packContext);
},
} satisfies AnyCommand,
]),
);
}
export async function loadAndValidateCommandPack(
name: string,
pjson: CommandPackPackageJson,
definition: CommandPackDefinition,
identity: Identity,
unresolvedPackageRoot: string = getCommandPackRoot(identity, name),
): Promise<Record<string, AnyCommand>> {
const meta = assertMeta(name, pjson, identity);
const packageRoot = await realpath(unresolvedPackageRoot);
const entry = await realpath(resolve(packageRoot, meta.entry));
const relativeEntry = relative(packageRoot, entry);
if (!relativeEntry || relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) {
throw new Error(`Command Pack entry must stay inside the package root: ${meta.entry}`);
}
const imported = (await import(pathToFileURL(entry).href)) as { default?: unknown };
const exported = imported.default;
if (!exported || typeof exported !== "object" || Array.isArray(exported)) {
throw new Error(`Command Pack "${name}" must default-export a command map.`);
}
const entries = Object.entries(exported);
if (entries.length === 0) {
throw new Error(`Command Pack "${name}" does not export any commands.`);
}
for (const [path, command] of entries) {
assertCommandPath(path, definition);
assertCommand(path, command);
}
const adapted = adaptCommandPack(name, exported as CommandPack, definition);
// Reuse the runtime's reserved-flag guard without duplicating its policy here.
new CommandRegistry(adapted, identity.binName);
return adapted;
}
+28 -9
View File
@@ -29,6 +29,9 @@ import {
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
import { loadCommandPacks } from "./command-packs/load.ts";
import { createCommandPackManager } from "./command-packs/manager.ts";
import type { CommandPackPolicy } from "./command-packs/types.ts";
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
export interface CliOptions {
@@ -42,6 +45,8 @@ export interface CliOptions {
npmPackage: string;
/** Root-help suggestions shown after credentials are configured. */
quickStartTasks?: readonly string[];
/** Command Packs accepted by this product. Omit when the product supports none. */
commandPacks?: CommandPackPolicy;
}
export interface Cli {
@@ -85,16 +90,27 @@ function installProcessHandlers(binName: string): void {
* then dispatches it.
*/
export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions): Cli {
const registry = new CommandRegistry(commands, opts.binName);
const { binName, version, npmPackage, clientName } = opts;
const identity: Identity = { binName, version, npmPackage, clientName };
const commandPackPolicy = opts.commandPacks ?? { supported: {} };
const commandPackManager = createCommandPackManager(identity, commandPackPolicy);
let registryPromise: Promise<CommandRegistry> | undefined;
installProcessHandlers(binName);
const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]);
function getRegistry(): Promise<CommandRegistry> {
if (!registryPromise) {
registryPromise = loadCommandPacks(commands, identity, commandPackPolicy).then(
(loaded) => new CommandRegistry(loaded.commands, binName),
);
}
return registryPromise;
}
/** Render help for `path`; root ([]) doubles as the onboarding / login guide. */
function renderHelp(path: string[], argv: string[]): void {
function renderHelp(registry: CommandRegistry, path: string[], argv: string[]): void {
registry.printHelp(path, process.stderr);
if (path.length > 0) return;
@@ -121,7 +137,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
}
}
async function dispatch(argv: string[]): Promise<void> {
async function dispatch(registry: CommandRegistry, argv: string[]): Promise<void> {
const res = resolve(argv, registry);
switch (res.kind) {
@@ -130,7 +146,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
return;
case "help":
renderHelp(res.path, argv);
renderHelp(registry, res.path, argv);
return;
case "usageError":
@@ -167,8 +183,9 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
flags: ownFlags,
settings,
sources,
configStore: () => makeConfigStore(),
authStore: () => makeAuthStore(sources),
configStore: makeConfigStore(),
authStore: makeAuthStore(sources),
commandPacks: commandPackManager,
client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }),
};
await runMiddleware(ctx);
@@ -190,9 +207,11 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
return {
run(argv: string[] = process.argv.slice(2)) {
return dispatch(argv).catch(
(err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void,
);
return getRegistry()
.then((registry) => dispatch(registry, argv))
.catch(
(err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void,
);
},
};
}
+7
View File
@@ -5,6 +5,13 @@
export { createCli } from "./create-cli.ts";
export type { Cli, CliOptions } from "./create-cli.ts";
// Command Pack product policy
export type {
CommandPackCredentialAccess,
CommandPackDefinition,
CommandPackPolicy,
} from "./command-packs/types.ts";
// Command routing
export { CommandRegistry } from "./registry.ts";
export type { Command, FlagDef, LocateResult } from "./registry.ts";
+7 -4
View File
@@ -3,6 +3,7 @@ import type {
ApiKeyCredential,
AuthStore,
ConfigStore,
CommandPackManager,
ConsoleCredential,
FlagsDef,
Identity,
@@ -45,10 +46,12 @@ export interface RunContext {
settings: Settings;
/** 解析源:provider/访问器用;业务命令不可见(窄视图类型不含此字段)。 */
sources: ResolutionSources;
/** 惰性访问器,lint 限定 commands/config/** 使用。 */
configStore(): ConfigStore;
/** 惰性访问器,lint 限定 commands/auth/** 使用。 */
authStore(): AuthStore;
/** 配置持久化能力;lint 限定 commands/config/** 使用。 */
configStore: ConfigStore;
/** 鉴权持久化能力;lint 限定 commands/auth/** 使用。 */
authStore: AuthStore;
/** Command Pack 管理能力;lint 限定 commands/plugin/** 使用。 */
commandPacks: CommandPackManager;
/** Network surface with the credential baked in — set by {@link authStage}. */
client: Client;
}
@@ -0,0 +1,255 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
import type { Identity } from "bailian-cli-core";
import { installCommandPack, parseCommandPackSpec } from "../src/command-packs/manager.ts";
import { getCommandPacksDir } from "../src/command-packs/paths.ts";
import { loadAndValidateCommandPack } from "../src/command-packs/validate.ts";
import type { CommandPackPackageJson, CommandPackPolicy } from "../src/command-packs/types.ts";
const packageRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../cli/tests/fixtures/command-pack",
);
const identity: Identity = {
binName: "bl",
version: "1.7.0",
clientName: "bailian-cli",
npmPackage: "bailian-cli",
};
const policy: CommandPackPolicy = {
supported: {
"@ali/bailian-plugin-agent": {
commandPrefixes: ["agent"],
credentialAccess: ["apiKey"],
},
},
};
const packageJson: CommandPackPackageJson = {
name: "@ali/bailian-plugin-agent",
version: "0.0.0-test",
bailianCli: {
type: "command-pack",
apiVersion: 1,
entry: "./commands.mjs",
minCliVersion: "1.7.0",
},
};
const fakeNpmSource = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const args = process.argv.slice(2);
const cwd = process.cwd();
const logPath = path.join(process.env.TMPDIR ?? cwd, "npm.log");
fs.appendFileSync(
logPath,
JSON.stringify({
args,
env: {
registry: process.env.NPM_CONFIG_REGISTRY ?? null,
catalog: process.env.npm_config_catalog ?? null,
recursive: process.env.npm_config_recursive ?? null,
overrides: process.env.npm_config_overrides ?? null,
},
}) + "\\n",
);
const manifestPath = path.join(cwd, "package.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
manifest.dependencies ??= {};
const name = "@ali/bailian-plugin-agent";
const packageRoot = path.join(cwd, "node_modules", "@ali", "bailian-plugin-agent");
if (args[0] === "uninstall") {
delete manifest.dependencies[name];
fs.rmSync(packageRoot, { recursive: true, force: true });
} else if (args[0] === "install") {
const requested = args[1] ?? "";
const broken = requested.endsWith("@broken");
const version = requested.endsWith("@1.0.0") ? "1.0.0" : "2.0.0";
manifest.dependencies[name] = version;
fs.mkdirSync(packageRoot, { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({
name,
version,
bailianCli: {
type: "command-pack",
apiVersion: broken ? 2 : 1,
entry: "./commands.mjs",
},
}),
);
fs.writeFileSync(
path.join(packageRoot, "commands.mjs"),
'export default { "agent ping": { description: "Ping", auth: "none", async run() {} } };\\n',
);
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\\n");
`;
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
test("only accepts a package supported by the current product policy", () => {
expect(parseCommandPackSpec("@ali/bailian-plugin-agent@beta", identity, policy)).toEqual({
name: "@ali/bailian-plugin-agent",
requested: "@ali/bailian-plugin-agent@beta",
});
expect(() =>
parseCommandPackSpec("@ali/bailian-plugin-agent@file:../pack", identity, policy),
).toThrow(/Unsupported Command Pack package spec/);
expect(() => parseCommandPackSpec("@ali/not-allowlisted", identity, policy)).toThrow(
/not allowlisted for bl/,
);
});
test("isolates each product in its own Command Pack sandbox", () => {
expect(getCommandPacksDir(identity).endsWith(join("plugins", "bailian-cli"))).toBe(true);
expect(
getCommandPacksDir({
...identity,
binName: "kscli",
clientName: "knowledge-studio-cli",
npmPackage: "knowledge-studio-cli",
}).endsWith(join("plugins", "knowledge-studio-cli")),
).toBe(true);
});
test("loads an API 1 Command Pack and preserves its command contract", async () => {
const commands = await loadAndValidateCommandPack(
"@ali/bailian-plugin-agent",
packageJson,
policy.supported["@ali/bailian-plugin-agent"]!,
identity,
packageRoot,
);
expect(Object.keys(commands)).toEqual([
"agent credential",
"agent credential-denied",
"agent fail",
"agent output",
"agent ping",
]);
expect(commands["agent credential"]?.auth).toBe("apiKey");
expect(commands["agent ping"]?.auth).toBe("none");
expect(commands["agent ping"]?.flags?.message).toMatchObject({ required: true, type: "string" });
});
test("rejects incompatible protocol versions and invalid command prefixes", async () => {
await expect(
loadAndValidateCommandPack(
"@ali/bailian-plugin-agent",
{ ...packageJson, bailianCli: { ...packageJson.bailianCli!, apiVersion: 2 } },
policy.supported["@ali/bailian-plugin-agent"]!,
identity,
packageRoot,
),
).rejects.toThrow(/Command Pack API 2 is not supported/);
await expect(
loadAndValidateCommandPack(
"@ali/bailian-plugin-agent",
packageJson,
{ commandPrefixes: ["other"] },
identity,
packageRoot,
),
).rejects.toThrow(/outside the allowed prefixes/);
});
test("installs once on success and restores the previous version after validation failure", async () => {
const root = await mkdtemp(join(tmpdir(), "command-pack-install-test-"));
const binDir = join(root, "bin");
const logPath = join(root, "npm.log");
const npmPath = join(binDir, "npm");
const previousConfigDir = process.env.BAILIAN_CONFIG_DIR;
const previousPath = process.env.PATH;
const previousTmpdir = process.env.TMPDIR;
const previousRegistry = process.env.NPM_CONFIG_REGISTRY;
const previousCatalog = process.env.npm_config_catalog;
const previousRecursive = process.env.npm_config_recursive;
const previousOverrides = process.env.npm_config_overrides;
try {
await mkdir(binDir, { recursive: true });
await writeFile(npmPath, fakeNpmSource);
await chmod(npmPath, 0o755);
await writeFile(logPath, "");
process.env.BAILIAN_CONFIG_DIR = join(root, "config");
process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
process.env.TMPDIR = root;
process.env.NPM_CONFIG_REGISTRY = "https://registry.example.test";
process.env.npm_config_catalog = "prefer";
process.env.npm_config_recursive = "true";
process.env.npm_config_overrides = "true";
const installed = await installCommandPack("@ali/bailian-plugin-agent@1.0.0", identity, policy);
expect(installed.version).toBe("1.0.0");
const successfulCalls = (await readFile(logPath, "utf8"))
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as {
args: string[];
env: Record<string, string | null>;
},
);
expect(successfulCalls).toHaveLength(1);
expect(successfulCalls[0]?.env).toEqual({
registry: "https://registry.example.test",
catalog: null,
recursive: null,
overrides: null,
});
await writeFile(logPath, "");
await expect(
installCommandPack("@ali/bailian-plugin-agent@broken", identity, policy),
).rejects.toThrow(/Command Pack API 2 is not supported/);
const rollbackCalls = (await readFile(logPath, "utf8"))
.trim()
.split("\n")
.map(
(line) =>
JSON.parse(line) as {
args: string[];
env: Record<string, string | null>;
},
);
expect(rollbackCalls).toHaveLength(2);
expect(rollbackCalls[0]?.args.slice(0, 2)).toEqual([
"install",
"@ali/bailian-plugin-agent@broken",
]);
expect(rollbackCalls[1]?.args.slice(0, 2)).toEqual([
"install",
"@ali/bailian-plugin-agent@1.0.0",
]);
const manifest = JSON.parse(
await readFile(join(root, "config", "plugins", "bailian-cli", "package.json"), "utf8"),
) as { dependencies?: Record<string, string> };
expect(manifest.dependencies?.["@ali/bailian-plugin-agent"]).toBe("1.0.0");
} finally {
restoreEnv("BAILIAN_CONFIG_DIR", previousConfigDir);
restoreEnv("PATH", previousPath);
restoreEnv("TMPDIR", previousTmpdir);
restoreEnv("NPM_CONFIG_REGISTRY", previousRegistry);
restoreEnv("npm_config_catalog", previousCatalog);
restoreEnv("npm_config_recursive", previousRecursive);
restoreEnv("npm_config_overrides", previousOverrides);
await rm(root, { recursive: true, force: true });
}
});
+5
View File
@@ -65,6 +65,10 @@ Use this index for the full quick index and global flags.
| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) |
| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) |
| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) |
| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) |
| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) |
| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) |
| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) |
| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) |
| `bl quota history` | View quota change history | [quota.md](quota.md) |
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
@@ -110,6 +114,7 @@ Use this index for the full quick index and global flags.
| `model` | `list` | [model.md](model.md) |
| `omni` | `(root)` | [omni.md](omni.md) |
| `pipeline` | `run`, `validate` | [pipeline.md](pipeline.md) |
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
| `search` | `web` | [search.md](search.md) |
| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) |
+103
View File
@@ -0,0 +1,103 @@
# `bl plugin` commands
> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand.
> Regenerate: `pnpm --filter bailian-cli run generate:reference`.
Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ------------------- | ------------------------------------------------------ |
| `bl plugin install` | Install or upgrade an allowlisted Command Pack |
| `bl plugin link` | Link an allowlisted local Command Pack for development |
| `bl plugin list` | List installed Command Packs and their load status |
| `bl plugin remove` | Remove an installed Command Pack |
## Command details
### `bl plugin install`
| Field | Value |
| --------------- | ---------------------------------------------- |
| **Name** | `plugin install` |
| **Description** | Install or upgrade an allowlisted Command Pack |
| **Usage** | `bl plugin install --package <name[@version]>` |
#### Flags
| Flag | Type | Required | Description |
| ---------------------------- | ------ | -------- | ------------------------------------------------------------ |
| `--package <name[@version]>` | string | yes | Allowlisted Command Pack package and optional version or tag |
#### Examples
```bash
bl plugin install --package @ali/bailian-plugin-agent
```
```bash
bl plugin install --package @ali/bailian-plugin-agent@beta
```
### `bl plugin link`
| Field | Value |
| --------------- | ------------------------------------------------------ |
| **Name** | `plugin link` |
| **Description** | Link an allowlisted local Command Pack for development |
| **Usage** | `bl plugin link --path <directory>` |
#### Flags
| Flag | Type | Required | Description |
| -------------------- | ------ | -------- | ------------------------------------ |
| `--path <directory>` | string | yes | Local Command Pack package directory |
#### Examples
```bash
bl plugin link --path ../bailian-plugin-agent
```
### `bl plugin list`
| Field | Value |
| --------------- | -------------------------------------------------- |
| **Name** | `plugin list` |
| **Description** | List installed Command Packs and their load status |
| **Usage** | `bl plugin list` |
#### Flags
_No command-specific flags._
#### Examples
```bash
bl plugin list
```
```bash
bl plugin list --output json
```
### `bl plugin remove`
| Field | Value |
| --------------- | ----------------------------------- |
| **Name** | `plugin remove` |
| **Description** | Remove an installed Command Pack |
| **Usage** | `bl plugin remove --name <package>` |
#### Flags
| Flag | Type | Required | Description |
| ------------------ | ------ | -------- | ------------------------------------- |
| `--name <package>` | string | yes | Allowlisted Command Pack package name |
#### Examples
```bash
bl plugin remove --name @ali/bailian-plugin-agent
```
+41
View File
@@ -1,5 +1,29 @@
import { defineConfig } from "vite-plus";
const commandCapabilityRestrictions = [
{
property: "configStore",
message: "configStore is only available to commands/config/**.",
},
{
property: "authStore",
message: "authStore is only available to commands/auth/**.",
},
{
property: "commandPacks",
message: "commandPacks is only available to commands/plugin/**.",
},
] as const;
type CommandCapabilityRestriction = (typeof commandCapabilityRestrictions)[number];
type CommandCapability = CommandCapabilityRestriction["property"];
function restrictCommandCapabilities(
allowed?: CommandCapability,
): ["error", ...CommandCapabilityRestriction[]] {
return ["error", ...commandCapabilityRestrictions.filter(({ property }) => property !== allowed)];
}
export default defineConfig({
test: {
globalSetup: "./packages/e2e/src/global-setup.ts",
@@ -25,6 +49,23 @@ export default defineConfig({
files: ["packages/commands/src/commands/finetune/watch.ts"],
rules: { "unicorn/no-process-exit": "off" },
},
{
// 普通业务命令只依赖 settings/flags/client;持久化和管理能力按命令族开放。
files: ["packages/commands/src/commands/**/*.ts"],
rules: { "no-restricted-properties": restrictCommandCapabilities() },
},
{
files: ["packages/commands/src/commands/config/**/*.ts"],
rules: { "no-restricted-properties": restrictCommandCapabilities("configStore") },
},
{
files: ["packages/commands/src/commands/auth/**/*.ts"],
rules: { "no-restricted-properties": restrictCommandCapabilities("authStore") },
},
{
files: ["packages/commands/src/commands/plugin/**/*.ts"],
rules: { "no-restricted-properties": restrictCommandCapabilities("commandPacks") },
},
],
},
run: {