diff --git a/src/extension-host/cli-lifecycle.test.ts b/src/extension-host/cli-lifecycle.test.ts new file mode 100644 index 00000000000..465efd9d623 --- /dev/null +++ b/src/extension-host/cli-lifecycle.test.ts @@ -0,0 +1,97 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import type { PluginLogger } from "../plugins/types.js"; +import { registerExtensionHostCliCommands } from "./cli-lifecycle.js"; + +function createLogger(): PluginLogger { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; +} + +describe("registerExtensionHostCliCommands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips overlapping command registrations", () => { + const program = new Command(); + program.command("memory"); + const registry = createEmptyPluginRegistry(); + const memoryRegister = vi.fn(); + const otherRegister = vi.fn(); + registry.cliRegistrars.push( + { + pluginId: "memory-core", + register: memoryRegister, + commands: ["memory"], + source: "bundled", + }, + { + pluginId: "other", + register: otherRegister, + commands: ["other"], + source: "bundled", + }, + ); + const logger = createLogger(); + + registerExtensionHostCliCommands({ + program, + registry, + config: {} as never, + workspaceDir: "/tmp/workspace", + logger, + }); + + expect(memoryRegister).not.toHaveBeenCalled(); + expect(otherRegister).toHaveBeenCalledOnce(); + expect(logger.debug).toHaveBeenCalledWith( + "plugin CLI register skipped (memory-core): command already registered (memory)", + ); + }); + + it("warns on sync and async registration failures", async () => { + const program = new Command(); + const registry = createEmptyPluginRegistry(); + registry.cliRegistrars.push( + { + pluginId: "sync-fail", + register: () => { + throw new Error("sync fail"); + }, + commands: ["sync"], + source: "bundled", + }, + { + pluginId: "async-fail", + register: async () => { + throw new Error("async fail"); + }, + commands: ["async"], + source: "bundled", + }, + ); + const logger = createLogger(); + + registerExtensionHostCliCommands({ + program, + registry, + config: {} as never, + workspaceDir: "/tmp/workspace", + logger, + }); + await Promise.resolve(); + + expect(logger.warn).toHaveBeenCalledWith( + "plugin CLI register failed (sync-fail): Error: sync fail", + ); + expect(logger.warn).toHaveBeenCalledWith( + "plugin CLI register failed (async-fail): Error: async fail", + ); + }); +}); diff --git a/src/extension-host/cli-lifecycle.ts b/src/extension-host/cli-lifecycle.ts new file mode 100644 index 00000000000..85f531a0aae --- /dev/null +++ b/src/extension-host/cli-lifecycle.ts @@ -0,0 +1,46 @@ +import type { Command } from "commander"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import type { PluginLogger } from "../plugins/types.js"; + +export function registerExtensionHostCliCommands(params: { + program: Command; + registry: PluginRegistry; + config: OpenClawConfig; + workspaceDir: string; + logger: PluginLogger; +}): void { + const existingCommands = new Set(params.program.commands.map((cmd) => cmd.name())); + + for (const entry of params.registry.cliRegistrars) { + if (entry.commands.length > 0) { + const overlaps = entry.commands.filter((command) => existingCommands.has(command)); + if (overlaps.length > 0) { + params.logger.debug( + `plugin CLI register skipped (${entry.pluginId}): command already registered (${overlaps.join( + ", ", + )})`, + ); + continue; + } + } + try { + const result = entry.register({ + program: params.program, + config: params.config, + workspaceDir: params.workspaceDir, + logger: params.logger, + }); + if (result && typeof result.then === "function") { + void result.catch((err) => { + params.logger.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); + }); + } + for (const command of entry.commands) { + existingCommands.add(command); + } + } catch (err) { + params.logger.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); + } + } +} diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 4d8af51e3db..3ff27c864c2 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { registerExtensionHostCliCommands } from "../extension-host/cli-lifecycle.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLogger } from "./types.js"; @@ -27,38 +28,11 @@ export function registerPluginCliCommands( env, logger, }); - - const existingCommands = new Set(program.commands.map((cmd) => cmd.name())); - - for (const entry of registry.cliRegistrars) { - if (entry.commands.length > 0) { - const overlaps = entry.commands.filter((command) => existingCommands.has(command)); - if (overlaps.length > 0) { - log.debug( - `plugin CLI register skipped (${entry.pluginId}): command already registered (${overlaps.join( - ", ", - )})`, - ); - continue; - } - } - try { - const result = entry.register({ - program, - config, - workspaceDir, - logger, - }); - if (result && typeof result.then === "function") { - void result.catch((err) => { - log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); - }); - } - for (const command of entry.commands) { - existingCommands.add(command); - } - } catch (err) { - log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`); - } - } + registerExtensionHostCliCommands({ + program, + registry, + config, + workspaceDir, + logger, + }); }