diff --git a/AGENTS.md b/AGENTS.md index 64d866a..a28fa7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.md`,把清单沉淀下来。 diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index d5ec52f..fde6101 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -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`: diff --git a/docs/agents/command-add-remove.md b/docs/agents/command-add-remove.md index fb333c1..471b140 100644 --- a/docs/agents/command-add-remove.md +++ b/docs/agents/command-add-remove.md @@ -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 diff --git a/docs/agents/command-pack.md b/docs/agents/command-pack.md new file mode 100644 index 0000000..1bec108 --- /dev/null +++ b/docs/agents/command-pack.md @@ -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 +``` diff --git a/packages/cli/src/command-pack-policy.ts b/packages/cli/src/command-pack-policy.ts new file mode 100644 index 0000000..abddbdf --- /dev/null +++ b/packages/cli/src/command-pack-policy.ts @@ -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; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 6957d52..d2fe6a0 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -77,6 +77,10 @@ import { tokenPlanCreateKey, tokenPlanAssignSeats, tokenPlanAddMember, + pluginInstall, + pluginLink, + pluginList, + pluginRemove, } from "bailian-cli-commands"; // Full bailian-cli product: every command, exposed under the `bl` binary. @@ -162,4 +166,8 @@ export const commands: Record = { "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, }; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 6377b94..f4b6e83 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -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(); diff --git a/packages/cli/tests/e2e/command-packs.e2e.test.ts b/packages/cli/tests/e2e/command-packs.e2e.test.ts new file mode 100644 index 0000000..0ee79be --- /dev/null +++ b/packages/cli/tests/e2e/command-packs.e2e.test.ts @@ -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); + }); +}); diff --git a/packages/cli/tests/fixtures/command-pack/commands.mjs b/packages/cli/tests/fixtures/command-pack/commands.mjs new file mode 100644 index 0000000..c6ba846 --- /dev/null +++ b/packages/cli/tests/fixtures/command-pack/commands.mjs @@ -0,0 +1,58 @@ +const ping = { + description: "Ping the Command Pack fixture", + auth: "none", + flags: { + message: { + type: "string", + valueHint: "", + required: true, + description: "Message returned by the fixture", + }, + }, + usageArgs: "--message ", + 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, +}; diff --git a/packages/cli/tests/fixtures/command-pack/package.json b/packages/cli/tests/fixtures/command-pack/package.json new file mode 100644 index 0000000..cb211b0 --- /dev/null +++ b/packages/cli/tests/fixtures/command-pack/package.json @@ -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" + } +} diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index d3e8561..ebf9ac9 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -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; diff --git a/packages/commands/src/commands/auth/logout.ts b/packages/commands/src/commands/auth/logout.ts index 53b7b88..0bfb79d 100644 --- a/packages/commands/src/commands/auth/logout.ts +++ b/packages/commands/src/commands/auth/logout.ts @@ -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) { diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index e46b252..e0acbbc 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -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 ? { diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 6715d01..99b3e83 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -106,7 +106,7 @@ export default defineCommand({ } const coerced = resolvedKey === "timeout" ? Number(value) : value; - await ctx.configStore().write({ [resolvedKey]: coerced } as Partial); + await ctx.configStore.write({ [resolvedKey]: coerced } as Partial); if (!settings.quiet) { const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced; diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index 228a2b1..6119b1e 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -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); diff --git a/packages/commands/src/commands/plugin/install.ts b/packages/commands/src/commands/plugin/install.ts new file mode 100644 index 0000000..838bec9 --- /dev/null +++ b/packages/commands/src/commands/plugin/install.ts @@ -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 ", + flags: { + package: { + type: "string", + valueHint: "", + 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)); + }, +}); diff --git a/packages/commands/src/commands/plugin/link.ts b/packages/commands/src/commands/plugin/link.ts new file mode 100644 index 0000000..4a232c1 --- /dev/null +++ b/packages/commands/src/commands/plugin/link.ts @@ -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 ", + flags: { + path: { + type: "string", + valueHint: "", + 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)); + }, +}); diff --git a/packages/commands/src/commands/plugin/list.ts b/packages/commands/src/commands/plugin/list.ts new file mode 100644 index 0000000..efc867e --- /dev/null +++ b/packages/commands/src/commands/plugin/list.ts @@ -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); + } + }, +}); diff --git a/packages/commands/src/commands/plugin/remove.ts b/packages/commands/src/commands/plugin/remove.ts new file mode 100644 index 0000000..6453300 --- /dev/null +++ b/packages/commands/src/commands/plugin/remove.ts @@ -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 ", + flags: { + name: { + type: "string", + valueHint: "", + 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)); + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index c3d857a..a549aae 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -84,3 +84,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"; diff --git a/packages/commands/tests/boundaries.test.ts b/packages/commands/tests/boundaries.test.ts deleted file mode 100644 index 31069b4..0000000 --- a/packages/commands/tests/boundaries.test.ts +++ /dev/null @@ -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); - } -}); diff --git a/packages/core/src/types/command-pack-manager.ts b/packages/core/src/types/command-pack-manager.ts new file mode 100644 index 0000000..057bfca --- /dev/null +++ b/packages/core/src/types/command-pack-manager.ts @@ -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; + link(path: string): Promise; + list(): Promise; + remove(name: string): Promise; +} diff --git a/packages/core/src/types/command-pack.ts b/packages/core/src/types/command-pack.ts new file mode 100644 index 0000000..ea925a6 --- /dev/null +++ b/packages/core/src/types/command-pack.ts @@ -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 { + identity: Identity; + settings: Settings; + flags: ParsedFlags; + client: Client; + output: CommandPackOutput; + errors: CommandPackErrors; +} + +/** Privileged context available only when product policy grants raw API-key access. */ +export type CommandPackApiKeyContext = CommandPackContext & { + credentials: CommandPackCredentials; +}; + +/** Command shape exported by an API 1 Command Pack. */ +export interface CommandPackCommand< + F extends FlagsDef = FlagsDef, + C extends CommandPackContext = CommandPackContext, +> extends Omit, "run"> { + run(ctx: C): Promise; +} + +/** A Command Pack explicitly maps product command paths to command implementations. */ +export type CommandPack = Record>; diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index f62e089..3facb5d 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -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 { flags: ParsedFlags; /** 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 ────────────────────────────────────────────────────────────────── diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 11202f2..bafd242 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -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, diff --git a/packages/kscli/tests/e2e/command-packs.e2e.test.ts b/packages/kscli/tests/e2e/command-packs.e2e.test.ts new file mode 100644 index 0000000..e3aed3b --- /dev/null +++ b/packages/kscli/tests/e2e/command-packs.e2e.test.ts @@ -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); + }); +}); diff --git a/packages/runtime/src/command-packs/load.ts b/packages/runtime/src/command-packs/load.ts new file mode 100644 index 0000000..7683c70 --- /dev/null +++ b/packages/runtime/src/command-packs/load.ts @@ -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; + reports: CommandPackReport[]; +} + +function sourceFromSpec(spec: string): "installed" | "linked" { + return spec.startsWith("file:") ? "linked" : "installed"; +} + +export async function loadCommandPacks( + builtins: Record, + identity: Identity, + policy: CommandPackPolicy, +): Promise { + 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 }; +} diff --git a/packages/runtime/src/command-packs/manager.ts b/packages/runtime/src/command-packs/manager.ts new file mode 100644 index 0000000..223279b --- /dev/null +++ b/packages/runtime/src/command-packs/manager.ts @@ -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 { + 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 { + await new Promise((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> { + 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(identity: Identity, run: () => Promise): Promise { + 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 { + 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 { + 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 { + 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), + }; +} diff --git a/packages/runtime/src/command-packs/package-json.ts b/packages/runtime/src/command-packs/package-json.ts new file mode 100644 index 0000000..75cb086 --- /dev/null +++ b/packages/runtime/src/command-packs/package-json.ts @@ -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(path: string): Promise { + 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 { + const path = getCommandPacksManifestPath(identity); + if (!existsSync(path)) return { dependencies: {} }; + return readJson(path); +} + +export async function readCommandPackPackageJson( + identity: Identity, + name: string, +): Promise { + return readCommandPackPackageJsonAt(getCommandPackRoot(identity, name)); +} + +export async function readCommandPackPackageJsonAt( + packageRoot: string, +): Promise { + return readJson(join(packageRoot, "package.json")); +} diff --git a/packages/runtime/src/command-packs/paths.ts b/packages/runtime/src/command-packs/paths.ts new file mode 100644 index 0000000..049fcdc --- /dev/null +++ b/packages/runtime/src/command-packs/paths.ts @@ -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("/")); +} diff --git a/packages/runtime/src/command-packs/types.ts b/packages/runtime/src/command-packs/types.ts new file mode 100644 index 0000000..f6f1729 --- /dev/null +++ b/packages/runtime/src/command-packs/types.ts @@ -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>; +} + +export interface CommandPackPackageJson { + name?: string; + version?: string; + bailianCli?: CommandPackMeta; +} + +export interface CommandPackSandboxManifest { + name?: string; + private?: boolean; + dependencies?: Record; +} diff --git a/packages/runtime/src/command-packs/validate.ts b/packages/runtime/src/command-packs/validate.ts new file mode 100644 index 0000000..6d231a9 --- /dev/null +++ b/packages/runtime/src/command-packs/validate.ts @@ -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 { + if (!value || typeof value !== "object") { + throw new Error(`Command "${path}" must export an object.`); + } + const command = value as Partial>; + 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 { + 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> { + 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; +} diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 986a30c..1106409 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -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, 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 | undefined; installProcessHandlers(binName); const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]); + function getRegistry(): Promise { + 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, opts: CliOptions } } - async function dispatch(argv: string[]): Promise { + async function dispatch(registry: CommandRegistry, argv: string[]): Promise { const res = resolve(argv, registry); switch (res.kind) { @@ -130,7 +146,7 @@ export function createCli(commands: Record, 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, 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, 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, + ); }, }; } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 5e96349..77463ad 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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"; diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index c459191..0bd9bff 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -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; } diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts new file mode 100644 index 0000000..19546d7 --- /dev/null +++ b/packages/runtime/tests/command-packs.test.ts @@ -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; + }, + ); + 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; + }, + ); + 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 }; + 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 }); + } +}); diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index d9eaa28..de570f0 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -64,6 +64,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) | @@ -107,6 +111,7 @@ Use this index for the full quick index and global flags. | `memory` | `add`, `delete`, `list`, `profile create`, `profile get`, `search`, `update` | [memory.md](memory.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) | diff --git a/skills/bailian-cli/reference/plugin.md b/skills/bailian-cli/reference/plugin.md new file mode 100644 index 0000000..f5ef485 --- /dev/null +++ b/skills/bailian-cli/reference/plugin.md @@ -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 ` | + +#### Flags + +| Flag | Type | Required | Description | +| ---------------------------- | ------ | -------- | ------------------------------------------------------------ | +| `--package ` | 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 ` | + +#### Flags + +| Flag | Type | Required | Description | +| -------------------- | ------ | -------- | ------------------------------------ | +| `--path ` | 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 ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | ------------------------------------- | +| `--name ` | string | yes | Allowlisted Command Pack package name | + +#### Examples + +```bash +bl plugin remove --name @ali/bailian-plugin-agent +``` diff --git a/vite.config.ts b/vite.config.ts index 36c4ad5..42efd4b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {