Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 路径
Expand All @@ -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 类型
Expand Down Expand Up @@ -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`,把清单沉淀下来。

Expand Down
4 changes: 2 additions & 2 deletions docs/agents/auth-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的窄接口操作落盘。

## 必查清单

Expand Down Expand Up @@ -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`:
Expand Down
3 changes: 2 additions & 1 deletion docs/agents/command-add-remove.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions docs/agents/command-pack.md
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions packages/cli/src/command-pack-policy.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -162,4 +166,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 changes: 2 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -15,4 +16,5 @@ void createCli(commands, {
clientName: "bailian-cli",
npmPackage: "bailian-cli",
quickStartTasks,
commandPacks: commandPackPolicy,
}).run();
134 changes: 134 additions & 0 deletions packages/cli/tests/e2e/command-packs.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 58 additions & 0 deletions packages/cli/tests/fixtures/command-pack/commands.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions packages/cli/tests/fixtures/command-pack/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion packages/commands/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/commands/src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/commands/src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? {
Expand Down
Loading