plugins/cli: lazy-load descriptor-backed plugin roots openclaw#57165 thanks @gumadeiras

This commit is contained in:
Gustavo Madeira Santana 2026-03-29 15:52:48 -04:00
parent d330782ed1
commit 1b892ee02a
No known key found for this signature in database
17 changed files with 409 additions and 71 deletions

View File

@ -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

View File

@ -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**

View File

@ -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,
},
],
},
);
},
});

View File

@ -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:

View File

@ -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

View File

@ -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<typeof import("./src/cli.js")>();
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 });
});
});

View File

@ -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,
},
],
},
);
},
});

View File

@ -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) {

View File

@ -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 });
},
});
}

View File

@ -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> | 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);
});
}

View File

@ -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);
},
});
}

View File

@ -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");
});
});

View File

@ -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;
}

View File

@ -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")

View File

@ -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);
});
});

View File

@ -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<PluginLoadOptions, "pluginSdkResolution">,
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);

View File

@ -1603,6 +1603,14 @@ export type OpenClawPluginCliContext = {
export type OpenClawPluginCliRegistrar = (ctx: OpenClawPluginCliContext) => void | Promise<void>;
/**
* 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;