diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index 22d7ce61abb..aad181eff7f 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -2,14 +2,32 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { loadConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { PluginLogger } from "../plugins/types.js"; const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded = false; +let pluginRegistryLoaded: "none" | "channels" | "all" = "none"; -export function ensurePluginRegistryLoaded(): void { - if (pluginRegistryLoaded) { +export type PluginRegistryScope = "channels" | "all"; + +function resolveChannelPluginIds(params: { + config: ReturnType; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter((plugin) => plugin.channels.length > 0) + .map((plugin) => plugin.id); +} + +export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { + const scope = options?.scope ?? "all"; + if (pluginRegistryLoaded === "all" || pluginRegistryLoaded === scope) { return; } const active = getActivePluginRegistry(); @@ -19,7 +37,7 @@ export function ensurePluginRegistryLoaded(): void { active && (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) ) { - pluginRegistryLoaded = true; + pluginRegistryLoaded = "all"; return; } const config = loadConfig(); @@ -34,6 +52,15 @@ export function ensurePluginRegistryLoaded(): void { config, workspaceDir, logger, + ...(scope === "channels" + ? { + onlyPluginIds: resolveChannelPluginIds({ + config, + workspaceDir, + env: process.env, + }), + } + : {}), }); - pluginRegistryLoaded = true; + pluginRegistryLoaded = scope; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 4353b8a0d18..2a1367870c6 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -149,7 +149,7 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["status"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); expect(process.title).toBe("openclaw-status"); vi.clearAllMocks(); @@ -164,7 +164,7 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["message", "send"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); }); it("skips help/version preaction and respects banner opt-out", async () => { diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 5e029c84858..ccd84e3201e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -67,6 +67,10 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } +function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { + return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; +} + function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -136,7 +140,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) // Load plugins for commands that need channel access if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded(); + ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); } }); } diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index c2b2270fd0a..93516906ad0 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -37,7 +37,7 @@ describe("tryRouteCli", () => { vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); findRoutedCommandMock.mockReturnValue({ - loadPlugins: false, + loadPlugins: true, run: runRouteMock, }); }); @@ -59,6 +59,7 @@ describe("tryRouteCli", () => { suppressDoctorStdout: true, }), ); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); it("does not pass suppressDoctorStdout for routed non-json commands", async () => { @@ -68,6 +69,7 @@ describe("tryRouteCli", () => { runtime: expect.any(Object), commandPath: ["status"], }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); it("routes status when root options precede the command", async () => { @@ -80,5 +82,6 @@ describe("tryRouteCli", () => { runtime: expect.any(Object), commandPath: ["status"], }); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); }); diff --git a/src/cli/route.ts b/src/cli/route.ts index b1d7b2851e1..763000a3d0b 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -22,7 +22,12 @@ async function prepareRoutedCommand(params: { const shouldLoadPlugins = typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins; if (shouldLoadPlugins) { - ensurePluginRegistryLoaded(); + ensurePluginRegistryLoaded({ + scope: + params.commandPath[0] === "status" || params.commandPath[0] === "health" + ? "channels" + : "all", + }); } } diff --git a/src/entry.test.ts b/src/entry.test.ts new file mode 100644 index 00000000000..8d444d5c205 --- /dev/null +++ b/src/entry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { tryHandleRootHelpFastPath } from "./entry.js"; + +describe("entry root help fast path", () => { + it("renders root help without importing the full program", () => { + const outputRootHelpMock = vi.fn(); + + const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { + outputRootHelp: outputRootHelpMock, + }); + + expect(handled).toBe(true); + expect(outputRootHelpMock).toHaveBeenCalledTimes(1); + }); + + it("ignores non-root help invocations", () => { + const outputRootHelpMock = vi.fn(); + + const handled = tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], { + outputRootHelp: outputRootHelpMock, + }); + + expect(handled).toBe(false); + expect(outputRootHelpMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/entry.ts b/src/entry.ts index 14a839f38b9..9b693c756e3 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -145,24 +145,6 @@ if ( return true; } - function tryHandleRootHelpFastPath(argv: string[]): boolean { - if (!isRootHelpInvocation(argv)) { - return false; - } - import("./cli/program.js") - .then(({ buildProgram }) => { - buildProgram().outputHelp(); - }) - .catch((error) => { - console.error( - "[openclaw] Failed to display help:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); - return true; - } - process.argv = normalizeWindowsArgv(process.argv); if (!ensureExperimentalWarningSuppressed()) { @@ -179,16 +161,58 @@ if ( process.argv = parsed.argv; } - if (!tryHandleRootVersionFastPath(process.argv) && !tryHandleRootHelpFastPath(process.argv)) { - import("./cli/run-main.js") - .then(({ runCli }) => runCli(process.argv)) - .catch((error) => { - console.error( - "[openclaw] Failed to start CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exitCode = 1; - }); + if (!tryHandleRootVersionFastPath(process.argv)) { + runMainOrRootHelp(process.argv); } } } + +export function tryHandleRootHelpFastPath( + argv: string[], + deps: { + outputRootHelp?: () => void; + onError?: (error: unknown) => void; + } = {}, +): boolean { + if (!isRootHelpInvocation(argv)) { + return false; + } + const handleError = + deps.onError ?? + ((error: unknown) => { + console.error( + "[openclaw] Failed to display help:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); + if (deps.outputRootHelp) { + try { + deps.outputRootHelp(); + } catch (error) { + handleError(error); + } + return true; + } + import("./cli/program/root-help.js") + .then(({ outputRootHelp }) => { + outputRootHelp(); + }) + .catch(handleError); + return true; +} + +function runMainOrRootHelp(argv: string[]): void { + if (tryHandleRootHelpFastPath(argv)) { + return; + } + import("./cli/run-main.js") + .then(({ runCli }) => runCli(argv)) + .catch((error) => { + console.error( + "[openclaw] Failed to start CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); +}