Skip to content

Commit 3c158ea

Browse files
committed
feat(cli): add plugin support
1 parent 58f2cce commit 3c158ea

25 files changed

Lines changed: 1457 additions & 40 deletions

File tree

packages/cli/src/commands/catalog.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import consoleCall from "./console/call.ts";
3535
import usageFree from "./usage/free.ts";
3636
import pipelineRun from "./pipeline/run.ts";
3737
import pipelineValidate from "./pipeline/validate.ts";
38+
import pluginsList from "./plugins/list.ts";
39+
import pluginsInstall from "./plugins/install.ts";
40+
import pluginsLink from "./plugins/link.ts";
41+
import pluginsRemove from "./plugins/remove.ts";
3842

3943
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
4044
export const commands: Record<string, Command> = {
@@ -69,6 +73,10 @@ export const commands: Record<string, Command> = {
6973
"usage free": usageFree,
7074
"pipeline run": pipelineRun,
7175
"pipeline validate": pipelineValidate,
76+
"plugins list": pluginsList,
77+
"plugins install": pluginsInstall,
78+
"plugins link": pluginsLink,
79+
"plugins remove": pluginsRemove,
7280
"config show": configShow,
7381
"config set": configSet,
7482
"config export-schema": configExportSchema,

packages/cli/src/commands/config/export-schema.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { defineCommand, generateToolSchema } from "bailian-cli-core";
1+
import { defineCommand, generateToolSchema, type Command } from "bailian-cli-core";
22
import type { Config } from "bailian-cli-core";
33
import type { GlobalFlags } from "bailian-cli-core";
44
import { BailianError } from "bailian-cli-core";
55
import { ExitCode } from "bailian-cli-core";
6+
import { loadCommandCatalog } from "../../load-commands.ts";
67

78
/**
89
* Commands that are infrastructure/auth-related and not suitable as Agent tools.
910
*/
10-
const SKIP_PREFIXES = ["auth ", "config ", "update"];
11+
const SKIP_PREFIXES = ["auth ", "config ", "update", "plugins "];
1112

1213
export default defineCommand({
1314
name: "config export-schema",
@@ -22,7 +23,7 @@ export default defineCommand({
2223
],
2324
examples: ["bl config export-schema", 'bl config export-schema --command "video generate"'],
2425
async run(config: Config, flags: GlobalFlags) {
25-
const { commands } = await import("../catalog.ts");
26+
const { commands } = await loadCommandCatalog();
2627
const targetCommand = flags.command as string | undefined;
2728

2829
if (targetCommand) {
@@ -35,8 +36,7 @@ export default defineCommand({
3536
return;
3637
}
3738

38-
// Export all suitable commands
39-
const allCommands = Object.values(commands);
39+
const allCommands = Object.values(commands) as Command[];
4040
const schemas = allCommands
4141
.filter((c) => !SKIP_PREFIXES.some((p) => c.name.startsWith(p)))
4242
.map((c) => generateToolSchema(c));
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
BailianError,
3+
ExitCode,
4+
defineCommand,
5+
type Config,
6+
type GlobalFlags,
7+
} from "bailian-cli-core";
8+
import { installPlugin } from "../../plugins/manager.ts";
9+
import { resetCommandCatalogCache } from "../../load-commands.ts";
10+
import { createRegistry, resetRegistry } from "../../registry.ts";
11+
12+
export default defineCommand({
13+
name: "plugins install",
14+
description: "Install a bailian-cli plugin package into ~/.bailian/plugins",
15+
usage: "bl plugins install <package>",
16+
options: [],
17+
examples: ["bl plugins install @alife/bailian-agent", "bl plugins install bailian-agent"],
18+
async run(_config: Config, flags: GlobalFlags) {
19+
const positional = flags._positional as string[] | undefined;
20+
const packageSpec = positional?.[0];
21+
if (!packageSpec) {
22+
throw new BailianError(
23+
"Missing plugin package name.",
24+
ExitCode.USAGE,
25+
"bl plugins install <package>",
26+
);
27+
}
28+
29+
const name = await installPlugin(packageSpec);
30+
resetCommandCatalogCache();
31+
resetRegistry();
32+
await createRegistry();
33+
34+
if (flags.output === "json") {
35+
process.stdout.write(JSON.stringify({ installed: name }, null, 2) + "\n");
36+
return;
37+
}
38+
process.stdout.write(`Installed plugin: ${name}\n`);
39+
},
40+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {
2+
BailianError,
3+
ExitCode,
4+
defineCommand,
5+
type Config,
6+
type GlobalFlags,
7+
} from "bailian-cli-core";
8+
import { linkPlugin } from "../../plugins/manager.ts";
9+
import { resetCommandCatalogCache } from "../../load-commands.ts";
10+
import { createRegistry, resetRegistry } from "../../registry.ts";
11+
12+
export default defineCommand({
13+
name: "plugins link",
14+
description: "Link a local bailian-cli plugin directory",
15+
usage: "bl plugins link <path>",
16+
examples: ["bl plugins link ../bailian-plugin-agent"],
17+
async run(_config: Config, flags: GlobalFlags) {
18+
const positional = flags._positional as string[] | undefined;
19+
const pluginPath = positional?.[0];
20+
if (!pluginPath) {
21+
throw new BailianError("Missing plugin path.", ExitCode.USAGE, "bl plugins link <path>");
22+
}
23+
24+
await linkPlugin(pluginPath);
25+
resetCommandCatalogCache();
26+
resetRegistry();
27+
await createRegistry();
28+
29+
if (flags.output === "json") {
30+
process.stdout.write(JSON.stringify({ linked: pluginPath }, null, 2) + "\n");
31+
return;
32+
}
33+
process.stdout.write(`Linked plugin at ${pluginPath}\n`);
34+
},
35+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { defineCommand, type Config, type GlobalFlags } from "bailian-cli-core";
2+
import { listPlugins } from "../../plugins/manager.ts";
3+
4+
export default defineCommand({
5+
name: "plugins list",
6+
description: "List installed and discovered bailian-cli plugins",
7+
usage: "bl plugins list",
8+
examples: ["bl plugins list"],
9+
async run(_config: Config, flags: GlobalFlags) {
10+
const { plugins, errors } = await listPlugins();
11+
const format = flags.output === "json" ? "json" : "text";
12+
13+
if (format === "json") {
14+
process.stdout.write(JSON.stringify({ plugins, errors }, null, 2) + "\n");
15+
return;
16+
}
17+
18+
if (plugins.length === 0) {
19+
process.stdout.write("No plugins installed.\n");
20+
} else {
21+
process.stdout.write("Plugins:\n");
22+
for (const p of plugins) {
23+
process.stdout.write(` ${p.name} (${p.source})${p.version ? ` @ ${p.version}` : ""}\n`);
24+
process.stdout.write(` ${p.root}\n`);
25+
}
26+
}
27+
28+
if (errors.length > 0) {
29+
process.stderr.write("\nPlugin errors:\n");
30+
for (const e of errors) {
31+
process.stderr.write(` ${e.plugin}: ${e.message}\n`);
32+
}
33+
}
34+
},
35+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {
2+
BailianError,
3+
ExitCode,
4+
defineCommand,
5+
type Config,
6+
type GlobalFlags,
7+
} from "bailian-cli-core";
8+
import { removePlugin } from "../../plugins/manager.ts";
9+
import { resetCommandCatalogCache } from "../../load-commands.ts";
10+
import { createRegistry, resetRegistry } from "../../registry.ts";
11+
12+
export default defineCommand({
13+
name: "plugins remove",
14+
description: "Remove an installed bailian-cli plugin",
15+
usage: "bl plugins remove <name>",
16+
examples: ["bl plugins remove @alife/bailian-agent"],
17+
async run(_config: Config, flags: GlobalFlags) {
18+
const positional = flags._positional as string[] | undefined;
19+
const name = positional?.[0];
20+
if (!name) {
21+
throw new BailianError("Missing plugin name.", ExitCode.USAGE, "bl plugins remove <name>");
22+
}
23+
24+
await removePlugin(name);
25+
resetCommandCatalogCache();
26+
resetRegistry();
27+
await createRegistry();
28+
29+
if (flags.output === "json") {
30+
process.stdout.write(JSON.stringify({ removed: name }, null, 2) + "\n");
31+
return;
32+
}
33+
process.stdout.write(`Removed plugin: ${name}\n`);
34+
},
35+
});

packages/cli/src/load-commands.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { commands as builtinCommands } from "./commands/catalog.ts";
2+
import { discoverPlugins } from "./plugins/discover.ts";
3+
import { mergeCommands } from "./plugins/priority.ts";
4+
import { scanAllPluginCommands } from "./plugins/scan.ts";
5+
import type { CommandCatalog } from "./plugins/types.ts";
6+
7+
let cachedCatalog: CommandCatalog | undefined;
8+
9+
/**
10+
* Load the command catalog.
11+
* @returns The command catalog.
12+
*/
13+
export async function loadCommandCatalog(): Promise<CommandCatalog> {
14+
if (cachedCatalog) return cachedCatalog;
15+
16+
const plugins = await discoverPlugins();
17+
const {
18+
entries: pluginEntries,
19+
pluginErrors: scanErrors,
20+
noAuthSetup: pluginNoAuth,
21+
} = await scanAllPluginCommands(plugins);
22+
23+
const { commands, pluginErrors: mergeErrors } = mergeCommands(builtinCommands, pluginEntries);
24+
25+
cachedCatalog = {
26+
commands,
27+
noAuthSetup: pluginNoAuth,
28+
plugins,
29+
pluginErrors: [...scanErrors, ...mergeErrors],
30+
};
31+
32+
return cachedCatalog;
33+
}
34+
35+
/**
36+
* Reset the command catalog cache.
37+
*/
38+
export function resetCommandCatalogCache(): void {
39+
cachedCatalog = undefined;
40+
}
41+
42+
export function getCachedCommandCatalog(): CommandCatalog | undefined {
43+
return cachedCatalog;
44+
}

packages/cli/src/main.ts

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { scanCommandPath, parseFlags } from "./args.ts";
2-
import { registry } from "./registry.ts";
2+
import { createRegistry, getActiveRegistry } from "./registry.ts";
3+
import { loadCommandCatalog } from "./load-commands.ts";
34
import {
45
GLOBAL_OPTIONS,
56
loadConfig,
@@ -21,32 +22,8 @@ import {
2122
setExecutingCommandPath,
2223
} from "./utils/command-help.ts";
2324

24-
registerCommandHelpPrinter((commandPath, out) => {
25-
const a = process.argv.slice(2);
26-
const ri = a.indexOf("--region");
27-
const region = ((ri >= 0 && a[ri + 1]) ||
28-
process.env.DASHSCOPE_REGION ||
29-
readConfigFile().region ||
30-
"cn") as Region;
31-
registry.printHelp(commandPath, out, region);
32-
});
33-
34-
// 优雅处理 Ctrl+C
35-
// 退出前尝试 best-effort 刷出埋点,让去抖队列中 / 在途的 fetch 请求有机会
36-
// 落网络;flush 与较短超时 race,保证 SIGINT 仍然响应及时。
37-
process.on("SIGINT", () => {
38-
process.stderr.write("\nInterrupted. Exiting.\n");
39-
void flushTelemetry(500).finally(() => process.exit(130));
40-
});
41-
42-
// 优雅处理 stdout EPIPE(例如管道到提前退出的 `mpv`)
43-
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
44-
if (e.code === "EPIPE") process.exit(0);
45-
else throw e;
46-
});
47-
4825
// 自己接管鉴权 或 根本不需要 API key 的命令
49-
const NO_AUTH_SETUP = [
26+
const BUILTIN_NO_AUTH_SETUP: string[][] = [
5027
["auth", "login"],
5128
["auth", "logout"],
5229
["config", "show"],
@@ -60,9 +37,31 @@ const NO_AUTH_SETUP = [
6037
["app", "list"],
6138
["console", "call"],
6239
["usage", "free"],
40+
["plugins", "list"],
41+
["plugins", "install"],
42+
["plugins", "link"],
43+
["plugins", "remove"],
6344
];
6445

46+
function matchesNoAuthSetup(commandPath: string[], rules: string[][]): boolean {
47+
return rules.some((cmd) => cmd.every((c, i) => commandPath[i] === c));
48+
}
49+
6550
async function main() {
51+
const registry = await createRegistry();
52+
const catalog = await loadCommandCatalog();
53+
const noAuthSetup = [...BUILTIN_NO_AUTH_SETUP, ...catalog.noAuthSetup];
54+
55+
registerCommandHelpPrinter((commandPath, out) => {
56+
const a = process.argv.slice(2);
57+
const ri = a.indexOf("--region");
58+
const region = ((ri >= 0 && a[ri + 1]) ||
59+
process.env.DASHSCOPE_REGION ||
60+
readConfigFile().region ||
61+
"cn") as Region;
62+
getActiveRegistry().printHelp(commandPath, out, region);
63+
});
64+
6665
const argv = process.argv.slice(2);
6766

6867
if (argv.includes("--version") || argv.includes("-v")) {
@@ -122,7 +121,7 @@ async function main() {
122121
config.clientName = "bailian-cli";
123122
config.clientVersion = CLI_VERSION;
124123

125-
const needsAuthSetup = !NO_AUTH_SETUP.some((cmd) => cmd.every((c, i) => commandPath[i] === c));
124+
const needsAuthSetup = !matchesNoAuthSetup(commandPath, noAuthSetup);
126125
if (needsAuthSetup) {
127126
await ensureApiKey(config);
128127
try {
@@ -168,10 +167,16 @@ async function main() {
168167
await flushTelemetry(1000);
169168
}
170169

170+
process.on("SIGINT", () => {
171+
process.stderr.write("\nInterrupted. Exiting.\n");
172+
void flushTelemetry(500).finally(() => process.exit(130));
173+
});
174+
175+
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
176+
if (e.code === "EPIPE") process.exit(0);
177+
else throw e;
178+
});
179+
171180
main().catch((err) => {
172-
// 在 handleError() 调用 process.exit() 之前刷出在途埋点。
173-
// 命令抛出的错误已被 trackCommandExecution 的 finally 块记录,
174-
// 但底层 tracker 有 ~500ms 的发送去抖。不主动 flush 的话,
175-
// 错误事件会随进程退出丢掉。
176181
void flushTelemetry(1000).finally(() => handleError(err));
177182
});

0 commit comments

Comments
 (0)