From 1b892ee02a288b253500f9820278d112df8441bb Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 29 Mar 2026 15:52:48 -0400 Subject: [PATCH] plugins/cli: lazy-load descriptor-backed plugin roots openclaw#57165 thanks @gumadeiras --- CHANGELOG.md | 1 + docs/plugins/architecture.md | 8 ++ docs/plugins/sdk-channel-plugins.md | 10 +- docs/plugins/sdk-entrypoints.md | 11 ++ docs/plugins/sdk-overview.md | 34 ++++++ extensions/matrix/index.test.ts | 51 +++++++++ extensions/matrix/index.ts | 10 +- src/cli/completion-cli.ts | 2 +- src/cli/program/command-registry.ts | 17 +-- src/cli/program/register-lazy-command.ts | 30 +++++ src/cli/program/register.subclis.ts | 22 ++-- src/cli/program/root-help.test.ts | 42 +++++++ src/cli/program/root-help.ts | 8 ++ src/cli/run-main.ts | 5 +- src/plugins/cli.test.ts | 138 +++++++++++++++++------ src/plugins/cli.ts | 75 ++++++++++-- src/plugins/types.ts | 16 +++ 17 files changed, 409 insertions(+), 71 deletions(-) create mode 100644 extensions/matrix/index.test.ts create mode 100644 src/cli/program/register-lazy-command.ts create mode 100644 src/cli/program/root-help.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 642fd1affa8..1e138e53b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai - Matrix/plugin loading: ship and source-load the crypto bootstrap runtime sidecar correctly so current `main` stops warning about failed Matrix bootstrap loads and `matrix/index` plugin-id mismatches on every invocation. (#53298) thanks @keithce. - iOS/Live Activities: mark the `ActivityKit` import in `LiveActivityManager.swift` as `@preconcurrency` so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman. - Plugins/Matrix: mirror the Matrix crypto WASM runtime dependency into the root packaged install and enforce root/plugin dependency parity so bundled Matrix E2EE crypto resolves correctly in shipped builds. (#57163) Thanks @gumadeiras. +- Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras. ## 2026.3.28 diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index bac7d22b7b0..c1eb2642e22 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -130,6 +130,14 @@ OpenClaw's plugin system has four layers: The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services. +For plugin CLI specifically, root command discovery is split in two phases: + +- parse-time metadata comes from `registerCli(..., { descriptors: [...] })` +- the real plugin CLI module can stay lazy and register on first invocation + +That keeps plugin-owned CLI code inside the plugin while still letting OpenClaw +reserve root command names before parsing. + The important design boundary: - discovery + config validation should work from **manifest/schema metadata** diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index c03629dcb5c..794eca94642 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -221,7 +221,15 @@ dispatch. .command("acme-chat") .description("Acme Chat management"); }, - { commands: ["acme-chat"] }, + { + descriptors: [ + { + name: "acme-chat", + description: "Acme Chat management", + hasSubcommands: false, + }, + ], + }, ); }, }); diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 9dac6cd6ea7..dd7471a50a4 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -93,6 +93,9 @@ export default defineChannelPluginEntry({ (typically via `createPluginRuntimeStore`). - `registerFull` only runs when `api.registrationMode === "full"`. It is skipped during setup-only loading. +- For plugin-owned root CLI commands, prefer `api.registerCli(..., { descriptors: [...] })` + when you want the command to stay lazy-loaded without disappearing from the + root CLI parse tree. ## `defineSetupPluginEntry` @@ -135,6 +138,14 @@ register(api) { } ``` +For CLI registrars specifically: + +- use `descriptors` when the registrar owns one or more root commands and you + want OpenClaw to lazy-load the real CLI module on first invocation +- make sure those descriptors cover every top-level command root exposed by the + registrar +- use `commands` alone only for eager compatibility paths + ## Plugin shapes OpenClaw classifies loaded plugins by their registration behavior: diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 3a7376d4573..26e57b1169b 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -149,6 +149,40 @@ methods: | `api.registerService(service)` | Background service | | `api.registerInteractiveHandler(registration)` | Interactive handler | +### CLI registration metadata + +`api.registerCli(registrar, opts?)` accepts two kinds of top-level metadata: + +- `commands`: explicit command roots owned by the registrar +- `descriptors`: parse-time command descriptors used for root CLI help, + routing, and lazy plugin CLI registration + +If you want a plugin command to stay lazy-loaded in the normal root CLI path, +provide `descriptors` that cover every top-level command root exposed by that +registrar. + +```typescript +api.registerCli( + async ({ program }) => { + const { registerMatrixCli } = await import("./src/cli.js"); + registerMatrixCli({ program }); + }, + { + descriptors: [ + { + name: "matrix", + description: "Manage Matrix accounts, verification, devices, and profile state", + hasSubcommands: true, + }, + ], + }, +); +``` + +Use `commands` by itself only when you do not need lazy root CLI registration. +That eager compatibility path remains supported, but it does not install +descriptor-backed placeholders for parse-time lazy loading. + ### CLI backend registration `api.registerCliBackend(...)` lets a plugin own the default config for a local diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts new file mode 100644 index 00000000000..0c28643a901 --- /dev/null +++ b/extensions/matrix/index.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js"; + +const cliMocks = vi.hoisted(() => ({ + registerMatrixCli: vi.fn(), +})); + +vi.mock("./src/cli.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + registerMatrixCli: cliMocks.registerMatrixCli, + }; +}); + +import matrixPlugin from "./index.js"; + +describe("matrix plugin", () => { + it("registers matrix CLI through a descriptor-backed lazy registrar", async () => { + const registerCli = vi.fn(); + const api = createTestPluginApi({ + id: "matrix", + name: "Matrix", + source: "test", + config: {}, + runtime: {} as never, + registerCli, + }); + + matrixPlugin.register(api); + + const registrar = registerCli.mock.calls[0]?.[0]; + expect(registerCli).toHaveBeenCalledWith(expect.any(Function), { + descriptors: [ + { + name: "matrix", + description: "Manage Matrix accounts, verification, devices, and profile state", + hasSubcommands: true, + }, + ], + }); + expect(typeof registrar).toBe("function"); + expect(cliMocks.registerMatrixCli).not.toHaveBeenCalled(); + + const program = { command: vi.fn() }; + const result = registrar?.({ program } as never); + + await result; + expect(cliMocks.registerMatrixCli).toHaveBeenCalledWith({ program }); + }); +}); diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index fce8376792c..776ed1ef2a0 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -44,7 +44,15 @@ export default defineChannelPluginEntry({ const { registerMatrixCli } = await import("./src/cli.js"); registerMatrixCli({ program }); }, - { commands: ["matrix"] }, + { + descriptors: [ + { + name: "matrix", + description: "Manage Matrix accounts, verification, devices, and profile state", + hasSubcommands: true, + }, + ], + }, ); }, }); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 0d9fd947013..8c3235f47e8 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -280,7 +280,7 @@ export function registerCompletionCli(program: Command) { const config = await loadValidatedConfigForPluginRegistration(); if (config) { const { registerPluginCliCommands } = await import("../plugins/cli.js"); - registerPluginCliCommands(program, config); + await registerPluginCliCommands(program, config, undefined, undefined, { mode: "eager" }); } if (options.writeState) { diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index d016380d1ef..95321560301 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; -import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommandByName } from "./command-tree.js"; import type { ProgramContext } from "./context.js"; import { @@ -8,6 +7,7 @@ import { getCoreCliCommandDescriptors, getCoreCliCommandsWithSubcommands, } from "./core-command-descriptors.js"; +import { registerLazyCommand } from "./register-lazy-command.js"; import { registerSubCliCommands } from "./register.subclis.js"; export { getCoreCliCommandDescriptors, getCoreCliCommandsWithSubcommands }; @@ -228,13 +228,14 @@ function registerLazyCoreCommand( entry: CoreCliEntry, command: CoreCliCommandDescriptor, ) { - const placeholder = program.command(command.name).description(command.description); - placeholder.allowUnknownOption(true); - placeholder.allowExcessArguments(true); - placeholder.action(async (...actionArgs) => { - removeEntryCommands(program, entry); - await entry.register({ program, ctx, argv: process.argv }); - await reparseProgramFromActionArgs(program, actionArgs); + registerLazyCommand({ + program, + name: command.name, + description: command.description, + removeNames: entry.commands.map((cmd) => cmd.name), + register: async () => { + await entry.register({ program, ctx, argv: process.argv }); + }, }); } diff --git a/src/cli/program/register-lazy-command.ts b/src/cli/program/register-lazy-command.ts new file mode 100644 index 00000000000..acf895049d9 --- /dev/null +++ b/src/cli/program/register-lazy-command.ts @@ -0,0 +1,30 @@ +import type { Command } from "commander"; +import { reparseProgramFromActionArgs } from "./action-reparse.js"; +import { removeCommandByName } from "./command-tree.js"; + +type RegisterLazyCommandParams = { + program: Command; + name: string; + description: string; + removeNames?: string[]; + register: () => Promise | void; +}; + +export function registerLazyCommand({ + program, + name, + description, + removeNames, + register, +}: RegisterLazyCommandParams): void { + const placeholder = program.command(name).description(description); + placeholder.allowUnknownOption(true); + placeholder.allowExcessArguments(true); + placeholder.action(async (...actionArgs) => { + for (const commandName of new Set(removeNames ?? [name])) { + removeCommandByName(program, commandName); + } + await register(); + await reparseProgramFromActionArgs(program, actionArgs); + }); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 5ace8c10441..8692b2307e0 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -2,8 +2,8 @@ import type { Command } from "commander"; import type { OpenClawConfig } from "../../config/config.js"; import { isTruthyEnvValue } from "../../infra/env.js"; import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; -import { reparseProgramFromActionArgs } from "./action-reparse.js"; -import { removeCommand, removeCommandByName } from "./command-tree.js"; +import { removeCommandByName } from "./command-tree.js"; +import { registerLazyCommand as registerLazyCommandPlaceholder } from "./register-lazy-command.js"; import { getSubCliCommandsWithSubcommands, getSubCliEntries as getSubCliEntryDescriptors, @@ -228,7 +228,7 @@ const entries: SubCliEntry[] = [ const { registerPluginCliCommands } = await import("../../plugins/cli.js"); const config = await loadValidatedConfigForPluginRegistration(); if (config) { - registerPluginCliCommands(program, config); + await registerPluginCliCommands(program, config); } const mod = await import("../pairing-cli.js"); mod.registerPairingCli(program); @@ -244,7 +244,7 @@ const entries: SubCliEntry[] = [ const { registerPluginCliCommands } = await import("../../plugins/cli.js"); const config = await loadValidatedConfigForPluginRegistration(); if (config) { - registerPluginCliCommands(program, config); + await registerPluginCliCommands(program, config); } }, }, @@ -328,13 +328,13 @@ export async function registerSubCliByName(program: Command, name: string): Prom } function registerLazyCommand(program: Command, entry: SubCliEntry) { - const placeholder = program.command(entry.name).description(entry.description); - placeholder.allowUnknownOption(true); - placeholder.allowExcessArguments(true); - placeholder.action(async (...actionArgs) => { - removeCommand(program, placeholder); - await entry.register(program); - await reparseProgramFromActionArgs(program, actionArgs); + registerLazyCommandPlaceholder({ + program, + name: entry.name, + description: entry.description, + register: async () => { + await entry.register(program); + }, }); } diff --git a/src/cli/program/root-help.test.ts b/src/cli/program/root-help.test.ts new file mode 100644 index 00000000000..fc1f585b012 --- /dev/null +++ b/src/cli/program/root-help.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./core-command-descriptors.js", () => ({ + getCoreCliCommandDescriptors: () => [ + { + name: "status", + description: "Show status", + }, + ], +})); + +vi.mock("./subcli-descriptors.js", () => ({ + getSubCliEntries: () => [ + { + name: "config", + description: "Manage config", + }, + ], +})); + +vi.mock("../../plugins/cli.js", () => ({ + getPluginCliCommandDescriptors: () => [ + { + name: "matrix", + description: "Matrix channel utilities", + hasSubcommands: true, + }, + ], +})); + +const { renderRootHelpText } = await import("./root-help.js"); + +describe("root help", () => { + it("includes plugin CLI descriptors alongside core and sub-CLI commands", () => { + const text = renderRootHelpText(); + + expect(text).toContain("status"); + expect(text).toContain("config"); + expect(text).toContain("matrix"); + expect(text).toContain("Matrix channel utilities"); + }); +}); diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index 317d8958d48..83a2ca87218 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import { getPluginCliCommandDescriptors } from "../../plugins/cli.js"; import { VERSION } from "../../version.js"; import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; @@ -25,6 +26,13 @@ function buildRootHelpProgram(): Command { program.command(command.name).description(command.description); existingCommands.add(command.name); } + for (const command of getPluginCliCommandDescriptors()) { + if (existingCommands.has(command.name)) { + continue; + } + program.command(command.name).description(command.description); + existingCommands.add(command.name); + } return program; } diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 9da6d9526cb..d52ab2ff33c 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -213,7 +213,10 @@ export async function runCli(argv: string[] = process.argv) { await import("./program/register.subclis.js"); const config = await loadValidatedConfigForPluginRegistration(); if (config) { - registerPluginCliCommands(program, config); + await registerPluginCliCommands(program, config, undefined, undefined, { + mode: "lazy", + primary, + }); if ( primary === "browser" && !program.commands.some((command) => command.name() === "browser") diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 8d98577aaa3..7de34581216 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js"; const mocks = vi.hoisted(() => ({ memoryRegister: vi.fn(), otherRegister: vi.fn(), + memoryListAction: vi.fn(), loadOpenClawPlugins: vi.fn(), applyPluginAutoEnable: vi.fn(), })); @@ -27,19 +28,34 @@ function createProgram(existingCommandName?: string) { return program; } -function createCliRegistry() { +function createCliRegistry(params?: { + memoryCommands?: string[]; + memoryDescriptors?: Array<{ + name: string; + description: string; + hasSubcommands: boolean; + }>; +}) { return { cliRegistrars: [ { pluginId: "memory-core", register: mocks.memoryRegister, - commands: ["memory"], + commands: params?.memoryCommands ?? ["memory"], + descriptors: params?.memoryDescriptors ?? [ + { + name: "memory", + description: "Memory commands", + hasSubcommands: true, + }, + ], source: "bundled", }, { pluginId: "other", register: mocks.otherRegister, commands: ["other"], + descriptors: [], source: "bundled", }, ], @@ -81,51 +97,37 @@ function expectAutoEnabledCliLoad(params: { expectPluginLoaderConfig(params.autoEnabledConfig); } -function expectCliRegistrarCalledWithConfig(config: OpenClawConfig) { - expect(mocks.memoryRegister).toHaveBeenCalledWith( - expect.objectContaining({ - config, - }), - ); -} - -function runRegisterPluginCliCommands(params: { - existingCommandName?: string; - config: OpenClawConfig; - env?: NodeJS.ProcessEnv; -}) { - const program = createProgram(params.existingCommandName); - registerPluginCliCommands(program, params.config, params.env); - return program; -} - describe("registerPluginCliCommands", () => { beforeEach(() => { - mocks.memoryRegister.mockClear(); - mocks.otherRegister.mockClear(); + mocks.memoryRegister.mockReset(); + mocks.memoryRegister.mockImplementation(({ program }: { program: Command }) => { + const memory = program.command("memory").description("Memory commands"); + memory.command("list").action(mocks.memoryListAction); + }); + mocks.otherRegister.mockReset(); + mocks.otherRegister.mockImplementation(({ program }: { program: Command }) => { + program.command("other").description("Other commands"); + }); + mocks.memoryListAction.mockReset(); mocks.loadOpenClawPlugins.mockReset(); mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry()); mocks.applyPluginAutoEnable.mockReset(); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); }); - it("skips plugin CLI registrars when commands already exist", () => { - runRegisterPluginCliCommands({ - existingCommandName: "memory", - config: {} as OpenClawConfig, - }); + it("skips plugin CLI registrars when commands already exist", async () => { + const program = createProgram("memory"); + + await registerPluginCliCommands(program, {} as OpenClawConfig); expect(mocks.memoryRegister).not.toHaveBeenCalled(); expect(mocks.otherRegister).toHaveBeenCalledTimes(1); }); - it("forwards an explicit env to plugin loading", () => { + it("forwards an explicit env to plugin loading", async () => { const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; - runRegisterPluginCliCommands({ - config: {} as OpenClawConfig, - env, - }); + await registerPluginCliCommands(createProgram(), {} as OpenClawConfig, env); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ @@ -134,15 +136,77 @@ describe("registerPluginCliCommands", () => { ); }); - it("loads plugin CLI commands from the auto-enabled config snapshot", () => { + it("loads plugin CLI commands from the auto-enabled config snapshot", async () => { const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); - runRegisterPluginCliCommands({ - config: rawConfig, - }); + await registerPluginCliCommands(createProgram(), rawConfig); expectAutoEnabledCliLoad({ rawConfig, autoEnabledConfig }); - expectCliRegistrarCalledWithConfig(autoEnabledConfig); + expect(mocks.memoryRegister).toHaveBeenCalledWith( + expect.objectContaining({ + config: autoEnabledConfig, + }), + ); + }); + + it("lazy-registers descriptor-backed plugin commands on first invocation", async () => { + const program = createProgram(); + program.exitOverride(); + + await registerPluginCliCommands(program, {} as OpenClawConfig, undefined, undefined, { + mode: "lazy", + }); + + expect(program.commands.map((command) => command.name())).toEqual(["memory", "other"]); + expect(mocks.memoryRegister).not.toHaveBeenCalled(); + expect(mocks.otherRegister).toHaveBeenCalledTimes(1); + + await program.parseAsync(["memory", "list"], { from: "user" }); + + expect(mocks.memoryRegister).toHaveBeenCalledTimes(1); + expect(mocks.memoryListAction).toHaveBeenCalledTimes(1); + }); + + it("falls back to eager registration when descriptors do not cover every command root", async () => { + mocks.loadOpenClawPlugins.mockReturnValue( + createCliRegistry({ + memoryCommands: ["memory", "memory-admin"], + memoryDescriptors: [ + { + name: "memory", + description: "Memory commands", + hasSubcommands: true, + }, + ], + }), + ); + mocks.memoryRegister.mockImplementation(({ program }: { program: Command }) => { + program.command("memory"); + program.command("memory-admin"); + }); + + await registerPluginCliCommands(createProgram(), {} as OpenClawConfig, undefined, undefined, { + mode: "lazy", + }); + + expect(mocks.memoryRegister).toHaveBeenCalledTimes(1); + }); + + it("registers a selected plugin primary eagerly during lazy startup", async () => { + const program = createProgram(); + program.exitOverride(); + + await registerPluginCliCommands(program, {} as OpenClawConfig, undefined, undefined, { + mode: "lazy", + primary: "memory", + }); + + expect(program.commands.filter((command) => command.name() === "memory")).toHaveLength(1); + + await program.parseAsync(["memory", "list"], { from: "user" }); + + expect(mocks.memoryRegister).toHaveBeenCalledTimes(1); + expect(mocks.memoryListAction).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index ab896791d3f..d2188078e97 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -1,5 +1,7 @@ import type { Command } from "commander"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { removeCommandByName } from "../cli/program/command-tree.js"; +import { registerLazyCommand } from "../cli/program/register-lazy-command.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; @@ -10,6 +12,24 @@ import type { PluginLogger } from "./types.js"; const log = createSubsystemLogger("plugins"); +type PluginCliRegistrationMode = "eager" | "lazy"; + +type RegisterPluginCliOptions = { + mode?: PluginCliRegistrationMode; + primary?: string | null; +}; + +function canRegisterPluginCliLazily(entry: { + commands: string[]; + descriptors: OpenClawPluginCliCommandDescriptor[]; +}): boolean { + if (entry.descriptors.length === 0) { + return false; + } + const descriptorNames = new Set(entry.descriptors.map((descriptor) => descriptor.name)); + return entry.commands.every((command) => descriptorNames.has(command)); +} + function loadPluginCliRegistry( cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, @@ -64,17 +84,40 @@ export function getPluginCliCommandDescriptors( } } -export function registerPluginCliCommands( +export async function registerPluginCliCommands( program: Command, cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, loaderOptions?: Pick, + options?: RegisterPluginCliOptions, ) { const { config, workspaceDir, logger, registry } = loadPluginCliRegistry(cfg, env, loaderOptions); + const mode = options?.mode ?? "eager"; + const primary = options?.primary ?? null; const existingCommands = new Set(program.commands.map((cmd) => cmd.name())); for (const entry of registry.cliRegistrars) { + const registerEntry = async () => { + await entry.register({ + program, + config, + workspaceDir, + logger, + }); + }; + + if (primary && entry.commands.includes(primary)) { + for (const commandName of new Set(entry.commands)) { + removeCommandByName(program, commandName); + } + await registerEntry(); + for (const command of entry.commands) { + existingCommands.add(command); + } + continue; + } + if (entry.commands.length > 0) { const overlaps = entry.commands.filter((command) => existingCommands.has(command)); if (overlaps.length > 0) { @@ -86,17 +129,27 @@ export function registerPluginCliCommands( 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)}`); - }); + if (mode === "lazy" && canRegisterPluginCliLazily(entry)) { + for (const descriptor of entry.descriptors) { + registerLazyCommand({ + program, + name: descriptor.name, + description: descriptor.description, + removeNames: entry.commands, + register: async () => { + await registerEntry(); + }, + }); + } + } else { + if (mode === "lazy" && entry.descriptors.length > 0) { + log.debug( + `plugin CLI lazy register fallback to eager (${entry.pluginId}): descriptors do not cover all command roots`, + ); + } + await registerEntry(); } for (const command of entry.commands) { existingCommands.add(command); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 03e9f33805e..f79a42d110e 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1603,6 +1603,14 @@ export type OpenClawPluginCliContext = { export type OpenClawPluginCliRegistrar = (ctx: OpenClawPluginCliContext) => void | Promise; +/** + * Top-level CLI metadata for plugin-owned commands. + * + * Descriptors are the parse-time contract for lazy plugin CLI registration. + * If you want OpenClaw to keep a plugin command lazy-loaded while still + * advertising it at the root CLI level, provide descriptors that cover every + * top-level command root registered by that plugin CLI surface. + */ export type OpenClawPluginCliCommandDescriptor = { name: string; description: string; @@ -1707,7 +1715,15 @@ export type OpenClawPluginApi = { registerCli: ( registrar: OpenClawPluginCliRegistrar, opts?: { + /** Explicit top-level command roots owned by this registrar. */ commands?: string[]; + /** + * Parse-time command descriptors for lazy root CLI registration. + * + * When descriptors cover every top-level command root, OpenClaw can keep + * the plugin registrar lazy in the normal root CLI path. Command-only + * registrations stay on the eager compatibility path. + */ descriptors?: OpenClawPluginCliCommandDescriptor[]; }, ) => void;