Skip to content

Commit 32c497d

Browse files
committed
feat(agent): add skill-list command and fix pr issues
1 parent 247bb82 commit 32c497d

12 files changed

Lines changed: 400 additions & 73 deletions

File tree

packages/cli/src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
managedAgentSessionRun,
106106
managedAgentSessionSend,
107107
managedAgentSessionEvents,
108+
managedAgentSkillList,
108109
} from "bailian-cli-commands";
109110

110111
// Full bailian-cli product: every command, exposed under the `bl` binary.
@@ -218,4 +219,5 @@ export const commands: Record<string, AnyCommand> = {
218219
"managed-agent session run": managedAgentSessionRun,
219220
"managed-agent session send": managedAgentSessionSend,
220221
"managed-agent session events": managedAgentSessionEvents,
222+
"managed-agent skill-list": managedAgentSkillList,
221223
};

packages/commands/src/commands/managed-agent/_engine/errors.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ function parseSdkResponseBody(raw: string): ApiErrorBody {
3838
* bl's error handler produces the right exit code and hint formatting.
3939
* SDK `UserError` → USAGE; SDK `ApiError` (server HTTP error) → GENERAL via
4040
* `mapApiError` (server message passed through verbatim, with
41-
* httpStatus/apiCode/requestId metadata for --output json); any other Error →
42-
* GENERAL (message passed through, per bl's "don't translate server errors"
43-
* boundary).
41+
* httpStatus/apiCode/requestId metadata for --output json); fetch transport
42+
* failures (`TypeError: fetch failed`) are rethrown untouched so the runtime
43+
* error handler maps them to NETWORK with an errno-specific hint, matching the
44+
* native client path; any other Error → GENERAL (message passed through, per
45+
* bl's "don't translate server errors" boundary).
4446
*/
4547
export async function withAgentErrors<T>(fn: () => Promise<T>): Promise<T> {
4648
try {
@@ -51,6 +53,9 @@ export async function withAgentErrors<T>(fn: () => Promise<T>): Promise<T> {
5153
if (error instanceof Error && isSdkApiError(error)) {
5254
throw mapApiError(error.statusCode, parseSdkResponseBody(error.responseBody));
5355
}
56+
// DNS/TCP/TLS failures from the SDK's fetch: keep the original TypeError so
57+
// the runtime error handler classifies it as NETWORK (exit 6) + errno hint.
58+
if (error instanceof TypeError && error.message === "fetch failed") throw error;
5459
if (error instanceof Error) throw new BailianError(error.message, ExitCode.GENERAL);
5560
throw error;
5661
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core";
2+
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
3+
import { listSkills } from "@openagentpack/sdk";
4+
import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
5+
import { withStdoutProtected } from "./_engine/console-capture.ts";
6+
import { withAgentErrors } from "./_engine/errors.ts";
7+
8+
const SKILL_SOURCES = ["custom", "official", "all"] as const;
9+
type SkillSource = (typeof SKILL_SOURCES)[number];
10+
11+
const SKILL_LIST_FLAGS = {
12+
file: {
13+
type: "string",
14+
valueHint: "<path>",
15+
description: "Config file path (default: agents.yaml)",
16+
},
17+
source: {
18+
type: "string",
19+
valueHint: "<source>",
20+
description:
21+
"Skill catalog: custom (workspace-uploaded, default), official (built-in), or all (both catalogs in one call)",
22+
},
23+
provider: {
24+
type: "string",
25+
valueHint: "<name>",
26+
description: "Target provider",
27+
},
28+
} satisfies FlagsDef;
29+
30+
export default defineCommand({
31+
description: "List skills from the provider's skill catalog",
32+
auth: "apiKey",
33+
usageArgs: "[--source custom|official|all] [--provider <name>] [--file <path>]",
34+
flags: SKILL_LIST_FLAGS,
35+
exampleArgs: [
36+
"",
37+
"--source official",
38+
"--source all --output json",
39+
"--source custom --provider bailian",
40+
],
41+
notes: [
42+
...CREDENTIALS_NOTE,
43+
"Providers without a skill listing API (e.g. ark) return an empty list.",
44+
"For agent-driven skill selection, use `--source all --output json`: one call returns both catalogs with per-skill `source` and `description` fields to pick from.",
45+
],
46+
validate: (f) =>
47+
f.source && !SKILL_SOURCES.includes(f.source as SkillSource)
48+
? "--source must be one of: custom, official, all."
49+
: undefined,
50+
async run(ctx) {
51+
const { settings, flags } = ctx;
52+
const format = detectOutputFormat(settings.output);
53+
const file = flags.file ?? "agents.yaml";
54+
const source = (flags.source as SkillSource | undefined) ?? "custom";
55+
56+
const skills = await withAgentErrors(() =>
57+
withStdoutProtected(async () => {
58+
const runtime = await buildAgentRuntime(ctx, file);
59+
if (source !== "all") {
60+
return listSkills(runtime, { provider: flags.provider, source });
61+
}
62+
// Both catalogs in one call; each entry carries its own `source` field.
63+
const [customSkills, officialSkills] = await Promise.all([
64+
listSkills(runtime, { provider: flags.provider, source: "custom" }),
65+
listSkills(runtime, { provider: flags.provider, source: "official" }),
66+
]);
67+
return [...customSkills, ...officialSkills];
68+
}),
69+
);
70+
71+
if (format === "json") {
72+
emitResult({ source, skills }, format);
73+
return;
74+
}
75+
if (skills.length === 0) {
76+
emitBare(source === "all" ? "No skills found." : `No ${source} skills found.`);
77+
return;
78+
}
79+
80+
const headers = ["ID", "NAME", "SOURCE", "STATUS", "VERSION", "CREATED"];
81+
const rows = skills.map((skill) => [
82+
skill.id,
83+
skill.name.slice(0, 32),
84+
skill.source,
85+
skill.status,
86+
skill.latest_version ?? "-",
87+
skill.created_at ?? "-",
88+
]);
89+
for (const line of formatTable(headers, rows)) emitBare(line);
90+
emitBare(`\nTotal: ${skills.length} (${source})`);
91+
},
92+
});

packages/commands/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export { default as managedAgentSessionDelete } from "./commands/managed-agent/s
107107
export { default as managedAgentSessionRun } from "./commands/managed-agent/session-run.ts";
108108
export { default as managedAgentSessionSend } from "./commands/managed-agent/session-send.ts";
109109
export { default as managedAgentSessionEvents } from "./commands/managed-agent/session-events.ts";
110+
export { default as managedAgentSkillList } from "./commands/managed-agent/skill-list.ts";
110111
export { default as workspaceInit } from "./commands/workspace/init.ts";
111112
export { default as pluginInstall } from "./commands/plugin/install.ts";
112113
export { default as pluginLink } from "./commands/plugin/link.ts";

packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { createServer } from "node:net";
23
import { tmpdir } from "node:os";
34
import { join } from "node:path";
45
import { afterEach, describe, expect, test } from "vite-plus/test";
5-
import { e2eFixturesDir, runCommandE2e } from "./helpers.ts";
6+
import { e2eFixturesDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
67
import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts";
78

89
/**
@@ -44,6 +45,21 @@ function validateArgs(file: string): string[] {
4445
return ["managed-agent", "validate", "--file", file, "--quiet"];
4546
}
4647

48+
/** 分配一个刚释放的本地端口,连接必然 ECONNREFUSED,用于网络错误场景。 */
49+
async function closedPort(): Promise<number> {
50+
const server = createServer();
51+
try {
52+
await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
53+
const address = server.address();
54+
if (!address || typeof address === "string") {
55+
throw new Error("failed to allocate a closed port");
56+
}
57+
return address.port;
58+
} finally {
59+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
60+
}
61+
}
62+
4763
describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错误映射)", () => {
4864
test("config.json 写入的 api_key 流入引擎,validate 离线通过", async () => {
4965
const env = makeConfigEnv({ api_key: "sk-e2e-config-write" });
@@ -96,4 +112,33 @@ describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错
96112
expect(stderr).toMatch(/agents/i);
97113
expect(stderr).not.toMatch(/"code":\s*"invalid_type"/);
98114
});
115+
116+
test("validate --output json 成功路径 stdout 为单个合法 JSON", async () => {
117+
const env = makeConfigEnv({ api_key: "sk-e2e-config-write" });
118+
const { stdout, stderr, exitCode } = await runCommandE2e(
119+
ROUTES,
120+
["managed-agent", "validate", "--file", AGENTS_YAML, "--output", "json"],
121+
env,
122+
);
123+
expect(exitCode, stderr).toBe(0);
124+
const data = parseStdoutJson<{ valid?: boolean; diagnostics?: unknown[] }>(stdout);
125+
expect(data.valid).toBe(true);
126+
expect(Array.isArray(data.diagnostics)).toBe(true);
127+
});
128+
129+
test("SDK fetch 连不上时映射为 NETWORK (6) + errno hint,不降级成 GENERAL", async () => {
130+
const port = await closedPort();
131+
const env = makeConfigEnv({
132+
api_key: "sk-e2e-network",
133+
base_url: `http://127.0.0.1:${port}`,
134+
});
135+
const { stderr, exitCode } = await runCommandE2e(
136+
ROUTES,
137+
["managed-agent", "session", "get", "--session-id", "sess_net", "--file", AGENTS_YAML],
138+
env,
139+
);
140+
expect(exitCode).toBe(6);
141+
expect(stderr).toMatch(/Network request failed/i);
142+
expect(stderr).toMatch(/ECONNREFUSED|refused/i);
143+
});
99144
});

packages/commands/tests/e2e/managed-agent.e2e.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,44 @@ describe("e2e: managed-agent", () => {
4343
expect(exitCode).toBe(2);
4444
expect(stderr).toMatch(/--message|Missing required/i);
4545
});
46+
47+
test("managed-agent skill-list --help 正常退出", async () => {
48+
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
49+
"managed-agent",
50+
"skill-list",
51+
"--help",
52+
]);
53+
expect(exitCode, stderr).toBe(0);
54+
expect(stderr).toMatch(/--source|--provider|--file/i);
55+
});
56+
57+
test("managed-agent skill-list 非法 --source 时退出为用法错误 (2)", async () => {
58+
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
59+
"managed-agent",
60+
"skill-list",
61+
"--source",
62+
"builtin",
63+
"--quiet",
64+
]);
65+
expect(exitCode).toBe(2);
66+
expect(stderr).toMatch(/--source must be one of: custom, official/i);
67+
});
68+
69+
test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => {
70+
const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [
71+
"managed-agent",
72+
"skill-list",
73+
"--source",
74+
"all",
75+
"--file",
76+
"agents.e2e-missing.yaml",
77+
"--quiet",
78+
]);
79+
// all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出
80+
expect(exitCode).toBe(2);
81+
expect(stderr).not.toMatch(/--source must be one of/i);
82+
expect(stderr).toMatch(/File not found.*agents\.e2e-missing\.yaml/i);
83+
});
4684
});
4785

4886
describe("e2e: managed-agent(--dry-run 短路,不联网不写盘)", () => {

packages/commands/tests/e2e/topic-routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
164164
"managed-agent state rm": "managedAgentStateRm",
165165
"managed-agent state import": "managedAgentStateImport",
166166
"managed-agent session create": "managedAgentSessionCreate",
167+
"managed-agent session get": "managedAgentSessionGet",
167168
"managed-agent session delete": "managedAgentSessionDelete",
168169
"managed-agent session run": "managedAgentSessionRun",
169170
"managed-agent session send": "managedAgentSessionSend",
171+
"managed-agent skill-list": "managedAgentSkillList",
170172
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { expect, test } from "vite-plus/test";
4+
5+
/**
6+
* 发布契约:最低 Node 版本。
7+
* 1) bl 全部发布包的 engines.node 必须一致(版本 bump 一动多动的另一面)。
8+
* 2) 外部运行时依赖 @openagentpack/sdk 的 engines 下限不得高于 bl 的下限,
9+
* 否则 Node 18/20 用户安装 bailian-cli 会触发 EBADENGINE / engine-strict 失败。
10+
* 当前固定的 beta 版本是已知冲突(上游降级已合入,等发版后 bump),用版本号
11+
* 白名单做棘轮:一旦升级依赖版本,本检查自动强制生效。
12+
*/
13+
14+
const repoRoot = join(import.meta.dirname, "..", "..", "..");
15+
const BL_PACKAGES = ["core", "runtime", "commands", "cli", "kscli"] as const;
16+
17+
/** 上游 engines 降级发版前的已知冲突版本;bump 依赖后请勿把新版本加进来。 */
18+
const KNOWN_SDK_ENGINE_CONFLICT_VERSIONS = new Set(["0.3.0-beta-8d9edcd-20260722"]);
19+
20+
interface PackageManifest {
21+
name: string;
22+
version: string;
23+
engines?: { node?: string };
24+
dependencies?: Record<string, string>;
25+
}
26+
27+
function readManifest(path: string): PackageManifest {
28+
return JSON.parse(readFileSync(path, "utf8")) as PackageManifest;
29+
}
30+
31+
/** 解析 ">=X.Y.Z" / ">=X" 形式的 engines 下限为可比较的 [major, minor, patch]。 */
32+
function parseEngineFloor(range: string): [number, number, number] {
33+
const matched = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
34+
if (!matched) throw new Error(`Unsupported engines range: ${range}`);
35+
return [Number(matched[1]), Number(matched[2] ?? 0), Number(matched[3] ?? 0)];
36+
}
37+
38+
function floorLessOrEqual(
39+
left: [number, number, number],
40+
right: [number, number, number],
41+
): boolean {
42+
for (let index = 0; index < 3; index++) {
43+
if (left[index]! !== right[index]!) return left[index]! < right[index]!;
44+
}
45+
return true;
46+
}
47+
48+
test("bl 全部发布包 engines.node 一致", () => {
49+
const floors = BL_PACKAGES.map((pkg) => {
50+
const manifest = readManifest(join(repoRoot, "packages", pkg, "package.json"));
51+
return { name: manifest.name, node: manifest.engines?.node };
52+
});
53+
const [first, ...rest] = floors;
54+
expect(first?.node).toMatch(/^>=\d+\.\d+\.\d+$/);
55+
for (const entry of rest) {
56+
expect(entry.node, `${entry.name} engines.node 与 ${first?.name} 不一致`).toBe(first?.node);
57+
}
58+
});
59+
60+
test("@openagentpack/sdk engines 下限不高于 bl 的最低 Node 版本", () => {
61+
const commandsManifest = readManifest(join(repoRoot, "packages", "commands", "package.json"));
62+
const blFloor = parseEngineFloor(commandsManifest.engines?.node ?? "");
63+
64+
const sdkManifest = readManifest(
65+
join(repoRoot, "packages", "commands", "node_modules", "@openagentpack", "sdk", "package.json"),
66+
);
67+
const sdkRange = sdkManifest.engines?.node;
68+
if (!sdkRange) return; // 无 engines 声明即不设限,兼容
69+
70+
if (KNOWN_SDK_ENGINE_CONFLICT_VERSIONS.has(sdkManifest.version)) return;
71+
72+
const sdkFloor = parseEngineFloor(sdkRange);
73+
expect(
74+
floorLessOrEqual(sdkFloor, blFloor),
75+
`@openagentpack/sdk@${sdkManifest.version} 要求 Node ${sdkRange},高于 bl 承诺的 ${commandsManifest.engines?.node};` +
76+
"这会让 Node 18/20 用户安装 bailian-cli 失败(EBADENGINE / engine-strict)。",
77+
).toBe(true);
78+
});

packages/commands/tests/managed-agent-errors.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,17 @@ test("plain Error maps to GENERAL with message passed through", async () => {
8686
expect(mapped.message).toBe("boom");
8787
expect(mapped.api).toBeUndefined();
8888
});
89+
90+
test("fetch transport TypeError is rethrown untouched for the runtime NETWORK mapping", async () => {
91+
const transportError = new TypeError("fetch failed", {
92+
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1"), {
93+
code: "ECONNREFUSED",
94+
}),
95+
});
96+
try {
97+
await withAgentErrors(() => Promise.reject(transportError));
98+
throw new Error("expected withAgentErrors to throw");
99+
} catch (error) {
100+
expect(error).toBe(transportError);
101+
}
102+
});

0 commit comments

Comments
 (0)