mirror of https://github.com/openclaw/openclaw.git
CLI: keep root help plugin descriptors non-activating (#57294)
Merged via squash.
Prepared head SHA: c8da48f689
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
parent
1efef8205c
commit
e5dac0c39e
|
|
@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
|
|||
- 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.
|
||||
- Plugins/CLI: collect root-help plugin descriptors through a dedicated non-activating CLI metadata path so enabled plugins keep validated config semantics without triggering runtime-only plugin registration work, while preserving runtime CLI command registration for legacy channel plugins that still wire commands from full registration. (#57294) thanks @gumadeiras.
|
||||
|
||||
## 2026.3.28
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
title: "Root Help CLI Descriptor Loader Note"
|
||||
summary: "Collect root-help plugin CLI descriptors through a dedicated non-activating loader path with validated config, awaited registration, and plugin-owned channel metadata."
|
||||
author: "Gustavo Madeira Santana"
|
||||
github_username: "gumadeiras"
|
||||
created: "2026-03-29"
|
||||
status: "implemented"
|
||||
---
|
||||
|
||||
This note covers the final implementation on PR #57294 after review found two remaining gaps in the earlier branch state:
|
||||
|
||||
- root help still depended on an activating plugin loader path
|
||||
- async `register()` implementations were still ignored during descriptor capture
|
||||
|
||||
Decision:
|
||||
|
||||
- Root help should be non-activating, not semantically different.
|
||||
- That means `openclaw --help` should keep loader semantics for enable-state, per-plugin config, duplicate precedence, config validation, and memory-slot gating.
|
||||
- Help should use a dedicated async CLI metadata collector instead of piggybacking on the general activating registry loader.
|
||||
- Channel plugins should keep ownership of their own root-help metadata wherever possible.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `loadOpenClawPluginCliRegistry()` in `src/plugins/loader.ts`.
|
||||
- The collector reuses plugin discovery, manifest loading, duplicate precedence, enable-state resolution, config validation, and memory-slot gating.
|
||||
- The collector always runs with `activate: false` and `cache: false`.
|
||||
- The collector awaits `register(api)` so async plugin registration contributes CLI metadata.
|
||||
- The collector only exposes `registerCli(...)` to plugin code; it does not activate services, tools, providers, or gateway handlers.
|
||||
- `getPluginCliCommandDescriptors()` and root-help rendering are now async and route through the dedicated collector.
|
||||
- `defineChannelPluginEntry(...)` gained an additive `registerCliMetadata(api)` seam so channel plugins can register root-help metadata without entering `registerFull(...)`, while full loads still collect the same CLI descriptors.
|
||||
- `extensions/matrix/index.ts` moved its CLI descriptor registration onto that seam.
|
||||
- `defineChannelPluginEntry(...)` now skips `setRuntime(...)` in `cli-metadata` mode so help rendering does not poison channel runtime stores with a fake runtime object.
|
||||
- `registerPluginCliCommands()` still uses the normal full plugin loader so legacy channel plugins that wired CLI commands inside `registerFull(...)` keep working until they adopt `registerCliMetadata(...)`.
|
||||
|
||||
Why this replaced the earlier approach:
|
||||
|
||||
- The original manual import loop in `src/plugins/cli.ts` dropped `api.pluginConfig`, which broke config-dependent CLI plugins.
|
||||
- The intermediate loader-flag approach still tied descriptor capture to the sync general loader path and left async `register()` unsupported.
|
||||
- The dedicated collector keeps the special behavior narrow and explicit instead of broadening the general loader contract further.
|
||||
|
||||
Regression coverage added:
|
||||
|
||||
- A loader test that proves CLI metadata loads still receive validated `pluginConfig`.
|
||||
- A loader test that proves channel CLI metadata capture uses the real channel entry, reports `registrationMode: "cli-metadata"`, and does not load `setupEntry`.
|
||||
- A loader test that proves async plugin `register()` contributes CLI descriptors during metadata collection.
|
||||
- A loader test that proves `cli-metadata` mode does not call `setRuntime(...)` for channel plugins.
|
||||
|
|
@ -214,7 +214,7 @@ dispatch.
|
|||
name: "Acme Chat",
|
||||
description: "Acme Chat channel plugin",
|
||||
plugin: acmeChatPlugin,
|
||||
registerFull(api) {
|
||||
registerCliMetadata(api) {
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
program
|
||||
|
|
@ -232,11 +232,17 @@ dispatch.
|
|||
},
|
||||
);
|
||||
},
|
||||
registerFull(api) {
|
||||
api.registerGatewayMethod(/* ... */);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`defineChannelPluginEntry` handles the setup/full registration split
|
||||
automatically. See
|
||||
Put channel-owned CLI descriptors in `registerCliMetadata(...)` so OpenClaw
|
||||
can show them in root help without activating the full channel runtime,
|
||||
while normal full loads still pick up the same descriptors for real command
|
||||
registration. Keep `registerFull(...)` for runtime-only work.
|
||||
`defineChannelPluginEntry` handles the registration-mode split automatically. See
|
||||
[Entry Points](/plugins/sdk-entrypoints#definechannelpluginentry) for all
|
||||
options.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ sidebarTitle: "Entry Points"
|
|||
summary: "Reference for definePluginEntry, defineChannelPluginEntry, and defineSetupPluginEntry"
|
||||
read_when:
|
||||
- You need the exact type signature of definePluginEntry or defineChannelPluginEntry
|
||||
- You want to understand registration mode (full vs setup)
|
||||
- You want to understand registration mode (full vs setup vs CLI metadata)
|
||||
- You are looking up entry point options
|
||||
---
|
||||
|
||||
|
|
@ -61,7 +61,8 @@ export default definePluginEntry({
|
|||
**Import:** `openclaw/plugin-sdk/core`
|
||||
|
||||
Wraps `definePluginEntry` with channel-specific wiring. Automatically calls
|
||||
`api.registerChannel({ plugin })` and gates `registerFull` on registration mode.
|
||||
`api.registerChannel({ plugin })`, exposes an optional root-help CLI metadata
|
||||
seam, and gates `registerFull` on registration mode.
|
||||
|
||||
```typescript
|
||||
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
||||
|
|
@ -72,30 +73,40 @@ export default defineChannelPluginEntry({
|
|||
description: "Short summary",
|
||||
plugin: myChannelPlugin,
|
||||
setRuntime: setMyRuntime,
|
||||
registerFull(api) {
|
||||
registerCliMetadata(api) {
|
||||
api.registerCli(/* ... */);
|
||||
},
|
||||
registerFull(api) {
|
||||
api.registerGatewayMethod(/* ... */);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default |
|
||||
| -------------- | ---------------------------------------------------------------- | -------- | ------------------- |
|
||||
| `id` | `string` | Yes | — |
|
||||
| `name` | `string` | Yes | — |
|
||||
| `description` | `string` | Yes | — |
|
||||
| `plugin` | `ChannelPlugin` | Yes | — |
|
||||
| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema |
|
||||
| `setRuntime` | `(runtime: PluginRuntime) => void` | No | — |
|
||||
| `registerFull` | `(api: OpenClawPluginApi) => void` | No | — |
|
||||
| Field | Type | Required | Default |
|
||||
| --------------------- | ---------------------------------------------------------------- | -------- | ------------------- |
|
||||
| `id` | `string` | Yes | — |
|
||||
| `name` | `string` | Yes | — |
|
||||
| `description` | `string` | Yes | — |
|
||||
| `plugin` | `ChannelPlugin` | Yes | — |
|
||||
| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema |
|
||||
| `setRuntime` | `(runtime: PluginRuntime) => void` | No | — |
|
||||
| `registerCliMetadata` | `(api: OpenClawPluginApi) => void` | No | — |
|
||||
| `registerFull` | `(api: OpenClawPluginApi) => void` | No | — |
|
||||
|
||||
- `setRuntime` is called during registration so you can store the runtime reference
|
||||
(typically via `createPluginRuntimeStore`).
|
||||
(typically via `createPluginRuntimeStore`). It is skipped during CLI metadata
|
||||
capture.
|
||||
- `registerCliMetadata` runs during both `api.registrationMode === "cli-metadata"`
|
||||
and `api.registrationMode === "full"`.
|
||||
Use it as the canonical place for channel-owned CLI descriptors so root help
|
||||
stays non-activating while normal CLI command registration remains compatible
|
||||
with full plugin loads.
|
||||
- `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.
|
||||
root CLI parse tree. For channel plugins, prefer registering those descriptors
|
||||
from `registerCliMetadata(...)` and keep `registerFull(...)` focused on runtime-only work.
|
||||
|
||||
## `defineSetupPluginEntry`
|
||||
|
||||
|
|
@ -123,17 +134,22 @@ unconfigured, or when deferred loading is enabled. See
|
|||
| `"full"` | Normal gateway startup | Everything |
|
||||
| `"setup-only"` | Disabled/unconfigured channel | Channel registration only |
|
||||
| `"setup-runtime"` | Setup flow with runtime available | Channel + lightweight runtime |
|
||||
| `"cli-metadata"` | Root help / CLI metadata capture | CLI descriptors only |
|
||||
|
||||
`defineChannelPluginEntry` handles this split automatically. If you use
|
||||
`definePluginEntry` directly for a channel, check mode yourself:
|
||||
|
||||
```typescript
|
||||
register(api) {
|
||||
if (api.registrationMode === "cli-metadata" || api.registrationMode === "full") {
|
||||
api.registerCli(/* ... */);
|
||||
if (api.registrationMode === "cli-metadata") return;
|
||||
}
|
||||
|
||||
api.registerChannel({ plugin: myPlugin });
|
||||
if (api.registrationMode !== "full") return;
|
||||
|
||||
// Heavy runtime-only registrations
|
||||
api.registerCli(/* ... */);
|
||||
api.registerService(/* ... */);
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -237,20 +237,20 @@ AI CLI backend such as `claude-cli` or `codex-cli`.
|
|||
|
||||
### API object fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------------ | ------------------------- | --------------------------------------------------------- |
|
||||
| `api.id` | `string` | Plugin id |
|
||||
| `api.name` | `string` | Display name |
|
||||
| `api.version` | `string?` | Plugin version (optional) |
|
||||
| `api.description` | `string?` | Plugin description (optional) |
|
||||
| `api.source` | `string` | Plugin source path |
|
||||
| `api.rootDir` | `string?` | Plugin root directory (optional) |
|
||||
| `api.config` | `OpenClawConfig` | Current config snapshot |
|
||||
| `api.pluginConfig` | `Record<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.config` |
|
||||
| `api.runtime` | `PluginRuntime` | [Runtime helpers](/plugins/sdk-runtime) |
|
||||
| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) |
|
||||
| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, or `"setup-runtime"` |
|
||||
| `api.resolvePath(input)` | `(string) => string` | Resolve path relative to plugin root |
|
||||
| Field | Type | Description |
|
||||
| ------------------------ | ------------------------- | ---------------------------------------------------------------- |
|
||||
| `api.id` | `string` | Plugin id |
|
||||
| `api.name` | `string` | Display name |
|
||||
| `api.version` | `string?` | Plugin version (optional) |
|
||||
| `api.description` | `string?` | Plugin description (optional) |
|
||||
| `api.source` | `string` | Plugin source path |
|
||||
| `api.rootDir` | `string?` | Plugin root directory (optional) |
|
||||
| `api.config` | `OpenClawConfig` | Current config snapshot |
|
||||
| `api.pluginConfig` | `Record<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.config` |
|
||||
| `api.runtime` | `PluginRuntime` | [Runtime helpers](/plugins/sdk-runtime) |
|
||||
| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) |
|
||||
| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, `"setup-runtime"`, or `"cli-metadata"` |
|
||||
| `api.resolvePath(input)` | `(string) => string` | Resolve path relative to plugin root |
|
||||
|
||||
## Internal module convention
|
||||
|
||||
|
|
|
|||
|
|
@ -330,15 +330,15 @@ export function tryGetRuntime() {
|
|||
|
||||
Beyond `api.runtime`, the API object also provides:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------------ | ------------------------- | --------------------------------------------------------- |
|
||||
| `api.id` | `string` | Plugin id |
|
||||
| `api.name` | `string` | Plugin display name |
|
||||
| `api.config` | `OpenClawConfig` | Current config snapshot |
|
||||
| `api.pluginConfig` | `Record<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.config` |
|
||||
| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) |
|
||||
| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, or `"setup-runtime"` |
|
||||
| `api.resolvePath(input)` | `(string) => string` | Resolve a path relative to the plugin root |
|
||||
| Field | Type | Description |
|
||||
| ------------------------ | ------------------------- | ---------------------------------------------------------------- |
|
||||
| `api.id` | `string` | Plugin id |
|
||||
| `api.name` | `string` | Plugin display name |
|
||||
| `api.config` | `OpenClawConfig` | Current config snapshot |
|
||||
| `api.pluginConfig` | `Record<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.config` |
|
||||
| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) |
|
||||
| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, `"setup-runtime"`, or `"cli-metadata"` |
|
||||
| `api.resolvePath(input)` | `(string) => string` | Resolve a path relative to the plugin root |
|
||||
|
||||
## Related
|
||||
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ import matrixPlugin from "./index.js";
|
|||
describe("matrix plugin", () => {
|
||||
it("registers matrix CLI through a descriptor-backed lazy registrar", async () => {
|
||||
const registerCli = vi.fn();
|
||||
const registerGatewayMethod = vi.fn();
|
||||
const api = createTestPluginApi({
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registrationMode: "cli-metadata",
|
||||
registerCli,
|
||||
registerGatewayMethod,
|
||||
});
|
||||
|
||||
matrixPlugin.register(api);
|
||||
|
|
@ -47,5 +50,26 @@ describe("matrix plugin", () => {
|
|||
|
||||
await result;
|
||||
expect(cliMocks.registerMatrixCli).toHaveBeenCalledWith({ program });
|
||||
expect(registerGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps runtime bootstrap and CLI metadata out of setup-only registration", () => {
|
||||
const registerCli = vi.fn();
|
||||
const registerGatewayMethod = vi.fn();
|
||||
const api = createTestPluginApi({
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registrationMode: "setup-only",
|
||||
registerCli,
|
||||
registerGatewayMethod,
|
||||
});
|
||||
|
||||
matrixPlugin.register(api);
|
||||
|
||||
expect(registerCli).not.toHaveBeenCalled();
|
||||
expect(registerGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,23 @@ export default defineChannelPluginEntry({
|
|||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
plugin: matrixPlugin,
|
||||
setRuntime: setMatrixRuntime,
|
||||
registerCliMetadata(api) {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
registerFull(api) {
|
||||
void import("./src/plugin-entry.runtime.js")
|
||||
.then(({ ensureMatrixCryptoRuntime }) =>
|
||||
|
|
@ -38,21 +55,5 @@ export default defineChannelPluginEntry({
|
|||
const { handleVerificationStatus } = await import("./src/plugin-entry.runtime.js");
|
||||
await handleVerificationStatus(ctx);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ vi.mock("./subcli-descriptors.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../../plugins/cli.js", () => ({
|
||||
getPluginCliCommandDescriptors: () => [
|
||||
getPluginCliCommandDescriptors: async () => [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
|
|
@ -35,8 +35,8 @@ vi.mock("../../plugins/cli.js", () => ({
|
|||
const { renderRootHelpText } = await import("./root-help.js");
|
||||
|
||||
describe("root help", () => {
|
||||
it("includes plugin CLI descriptors alongside core and sub-CLI commands", () => {
|
||||
const text = renderRootHelpText();
|
||||
it("includes plugin CLI descriptors alongside core and sub-CLI commands", async () => {
|
||||
const text = await renderRootHelpText();
|
||||
|
||||
expect(text).toContain("status");
|
||||
expect(text).toContain("config");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js";
|
|||
import { configureProgramHelp } from "./help.js";
|
||||
import { getSubCliEntries } from "./subcli-descriptors.js";
|
||||
|
||||
function buildRootHelpProgram(): Command {
|
||||
async function buildRootHelpProgram(): Promise<Command> {
|
||||
const program = new Command();
|
||||
configureProgramHelp(program, {
|
||||
programVersion: VERSION,
|
||||
|
|
@ -26,7 +26,7 @@ function buildRootHelpProgram(): Command {
|
|||
program.command(command.name).description(command.description);
|
||||
existingCommands.add(command.name);
|
||||
}
|
||||
for (const command of getPluginCliCommandDescriptors()) {
|
||||
for (const command of await getPluginCliCommandDescriptors()) {
|
||||
if (existingCommands.has(command.name)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -37,8 +37,8 @@ function buildRootHelpProgram(): Command {
|
|||
return program;
|
||||
}
|
||||
|
||||
export function renderRootHelpText(): string {
|
||||
const program = buildRootHelpProgram();
|
||||
export async function renderRootHelpText(): Promise<string> {
|
||||
const program = await buildRootHelpProgram();
|
||||
let output = "";
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
const captureWrite: typeof process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
|
|
@ -54,6 +54,6 @@ export function renderRootHelpText(): string {
|
|||
return output;
|
||||
}
|
||||
|
||||
export function outputRootHelp(): void {
|
||||
process.stdout.write(renderRootHelpText());
|
||||
export async function outputRootHelp(): Promise<void> {
|
||||
process.stdout.write(await renderRootHelpText());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export async function runCli(argv: string[] = process.argv) {
|
|||
try {
|
||||
if (shouldUseRootHelpFastPath(normalizedArgv)) {
|
||||
const { outputRootHelp } = await import("./program/root-help.js");
|
||||
outputRootHelp();
|
||||
await outputRootHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ 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", () => {
|
||||
it("renders root help without importing the full program", async () => {
|
||||
const outputRootHelpMock = vi.fn();
|
||||
|
||||
const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], {
|
||||
outputRootHelp: outputRootHelpMock,
|
||||
env: {},
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
|
||||
|
|
|
|||
12
src/entry.ts
12
src/entry.ts
|
|
@ -159,7 +159,7 @@ if (
|
|||
export function tryHandleRootHelpFastPath(
|
||||
argv: string[],
|
||||
deps: {
|
||||
outputRootHelp?: () => void;
|
||||
outputRootHelp?: () => void | Promise<void>;
|
||||
onError?: (error: unknown) => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {},
|
||||
|
|
@ -180,16 +180,14 @@ export function tryHandleRootHelpFastPath(
|
|||
process.exitCode = 1;
|
||||
});
|
||||
if (deps.outputRootHelp) {
|
||||
try {
|
||||
deps.outputRootHelp();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => deps.outputRootHelp?.())
|
||||
.catch(handleError);
|
||||
return true;
|
||||
}
|
||||
import("./cli/program/root-help.js")
|
||||
.then(({ outputRootHelp }) => {
|
||||
outputRootHelp();
|
||||
return outputRootHelp();
|
||||
})
|
||||
.catch(handleError);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ type DefineChannelPluginEntryOptions<TPlugin = ChannelPlugin> = {
|
|||
plugin: TPlugin;
|
||||
configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
|
||||
setRuntime?: (runtime: PluginRuntime) => void;
|
||||
registerCliMetadata?: (api: OpenClawPluginApi) => void;
|
||||
registerFull?: (api: OpenClawPluginApi) => void;
|
||||
};
|
||||
|
||||
|
|
@ -281,6 +282,7 @@ export function defineChannelPluginEntry<TPlugin>({
|
|||
plugin,
|
||||
configSchema = emptyPluginConfigSchema,
|
||||
setRuntime,
|
||||
registerCliMetadata,
|
||||
registerFull,
|
||||
}: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin> {
|
||||
const resolvedConfigSchema = typeof configSchema === "function" ? configSchema() : configSchema;
|
||||
|
|
@ -290,11 +292,16 @@ export function defineChannelPluginEntry<TPlugin>({
|
|||
description,
|
||||
configSchema: resolvedConfigSchema,
|
||||
register(api: OpenClawPluginApi) {
|
||||
if (api.registrationMode === "cli-metadata") {
|
||||
registerCliMetadata?.(api);
|
||||
return;
|
||||
}
|
||||
setRuntime?.(api.runtime);
|
||||
api.registerChannel({ plugin: plugin as ChannelPlugin });
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
registerCliMetadata?.(api);
|
||||
registerFull?.(api);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,14 +7,23 @@ import type {
|
|||
ImageGenerationProviderPlugin,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginCliCommandDescriptor,
|
||||
OpenClawPluginCliRegistrar,
|
||||
ProviderPlugin,
|
||||
SpeechProviderPlugin,
|
||||
WebSearchProviderPlugin,
|
||||
} from "./types.js";
|
||||
|
||||
type CapturedPluginCliRegistration = {
|
||||
register: OpenClawPluginCliRegistrar;
|
||||
commands: string[];
|
||||
descriptors: OpenClawPluginCliCommandDescriptor[];
|
||||
};
|
||||
|
||||
export type CapturedPluginRegistration = {
|
||||
api: OpenClawPluginApi;
|
||||
providers: ProviderPlugin[];
|
||||
cliRegistrars: CapturedPluginCliRegistration[];
|
||||
cliBackends: CliBackendPlugin[];
|
||||
speechProviders: SpeechProviderPlugin[];
|
||||
mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[];
|
||||
|
|
@ -23,8 +32,12 @@ export type CapturedPluginRegistration = {
|
|||
tools: AnyAgentTool[];
|
||||
};
|
||||
|
||||
export function createCapturedPluginRegistration(): CapturedPluginRegistration {
|
||||
export function createCapturedPluginRegistration(params?: {
|
||||
config?: OpenClawConfig;
|
||||
registrationMode?: OpenClawPluginApi["registrationMode"];
|
||||
}): CapturedPluginRegistration {
|
||||
const providers: ProviderPlugin[] = [];
|
||||
const cliRegistrars: CapturedPluginCliRegistration[] = [];
|
||||
const cliBackends: CliBackendPlugin[] = [];
|
||||
const speechProviders: SpeechProviderPlugin[] = [];
|
||||
const mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[] = [];
|
||||
|
|
@ -40,6 +53,7 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration {
|
|||
|
||||
return {
|
||||
providers,
|
||||
cliRegistrars,
|
||||
cliBackends,
|
||||
speechProviders,
|
||||
mediaUnderstandingProviders,
|
||||
|
|
@ -50,12 +64,35 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration {
|
|||
id: "captured-plugin-registration",
|
||||
name: "Captured Plugin Registration",
|
||||
source: "captured-plugin-registration",
|
||||
registrationMode: "full",
|
||||
config: {} as OpenClawConfig,
|
||||
registrationMode: params?.registrationMode ?? "full",
|
||||
config: params?.config ?? ({} as OpenClawConfig),
|
||||
runtime: {} as PluginRuntime,
|
||||
logger: noopLogger,
|
||||
resolvePath: (input) => input,
|
||||
handlers: {
|
||||
registerCli(registrar, opts) {
|
||||
const descriptors = (opts?.descriptors ?? [])
|
||||
.map((descriptor) => ({
|
||||
name: descriptor.name.trim(),
|
||||
description: descriptor.description.trim(),
|
||||
hasSubcommands: descriptor.hasSubcommands,
|
||||
}))
|
||||
.filter((descriptor) => descriptor.name && descriptor.description);
|
||||
const commands = [
|
||||
...(opts?.commands ?? []),
|
||||
...descriptors.map((descriptor) => descriptor.name),
|
||||
]
|
||||
.map((command) => command.trim())
|
||||
.filter(Boolean);
|
||||
if (commands.length === 0) {
|
||||
return;
|
||||
}
|
||||
cliRegistrars.push({
|
||||
register: registrar,
|
||||
commands,
|
||||
descriptors,
|
||||
});
|
||||
},
|
||||
registerProvider(provider: ProviderPlugin) {
|
||||
providers.push(provider);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ const mocks = vi.hoisted(() => ({
|
|||
memoryRegister: vi.fn(),
|
||||
otherRegister: vi.fn(),
|
||||
memoryListAction: vi.fn(),
|
||||
loadOpenClawPluginCliRegistry: vi.fn(),
|
||||
loadOpenClawPlugins: vi.fn(),
|
||||
applyPluginAutoEnable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./loader.js", () => ({
|
||||
loadOpenClawPluginCliRegistry: (...args: unknown[]) =>
|
||||
mocks.loadOpenClawPluginCliRegistry(...args),
|
||||
loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args),
|
||||
}));
|
||||
|
||||
|
|
@ -18,7 +21,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
|
|||
applyPluginAutoEnable: (...args: unknown[]) => mocks.applyPluginAutoEnable(...args),
|
||||
}));
|
||||
|
||||
import { registerPluginCliCommands } from "./cli.js";
|
||||
import { getPluginCliCommandDescriptors, registerPluginCliCommands } from "./cli.js";
|
||||
|
||||
function createProgram(existingCommandName?: string) {
|
||||
const program = new Command();
|
||||
|
|
@ -109,6 +112,8 @@ describe("registerPluginCliCommands", () => {
|
|||
program.command("other").description("Other commands");
|
||||
});
|
||||
mocks.memoryListAction.mockReset();
|
||||
mocks.loadOpenClawPluginCliRegistry.mockReset();
|
||||
mocks.loadOpenClawPluginCliRegistry.mockResolvedValue(createCliRegistry());
|
||||
mocks.loadOpenClawPlugins.mockReset();
|
||||
mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry());
|
||||
mocks.applyPluginAutoEnable.mockReset();
|
||||
|
|
@ -150,6 +155,82 @@ describe("registerPluginCliCommands", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("loads root-help descriptors through the dedicated non-activating CLI collector", async () => {
|
||||
const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture();
|
||||
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
|
||||
mocks.loadOpenClawPluginCliRegistry.mockResolvedValue({
|
||||
cliRegistrars: [
|
||||
{
|
||||
pluginId: "matrix",
|
||||
register: vi.fn(),
|
||||
commands: ["matrix"],
|
||||
descriptors: [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
source: "bundled",
|
||||
},
|
||||
{
|
||||
pluginId: "duplicate-matrix",
|
||||
register: vi.fn(),
|
||||
commands: ["matrix"],
|
||||
descriptors: [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Duplicate Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
source: "bundled",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(getPluginCliCommandDescriptors(rawConfig)).resolves.toEqual([
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
]);
|
||||
expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: autoEnabledConfig,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps runtime CLI command registration on the full plugin loader for legacy channel plugins", async () => {
|
||||
const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture();
|
||||
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
|
||||
mocks.loadOpenClawPlugins.mockReturnValue(
|
||||
createCliRegistry({
|
||||
memoryCommands: ["legacy-channel"],
|
||||
memoryDescriptors: [
|
||||
{
|
||||
name: "legacy-channel",
|
||||
description: "Legacy channel commands",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await registerPluginCliCommands(createProgram(), rawConfig, undefined, undefined, {
|
||||
mode: "lazy",
|
||||
});
|
||||
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: autoEnabledConfig,
|
||||
}),
|
||||
);
|
||||
expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lazy-registers descriptor-backed plugin commands on first invocation", async () => {
|
||||
const program = createProgram();
|
||||
program.exitOverride();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||
import { loadConfig } from "../config/config.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
|
||||
import {
|
||||
loadOpenClawPluginCliRegistry,
|
||||
loadOpenClawPlugins,
|
||||
type PluginLoadOptions,
|
||||
} from "./loader.js";
|
||||
import type { OpenClawPluginCliCommandDescriptor } from "./types.js";
|
||||
import type { PluginLogger } from "./types.js";
|
||||
|
||||
|
|
@ -30,11 +34,7 @@ function canRegisterPluginCliLazily(entry: {
|
|||
return entry.commands.every((command) => descriptorNames.has(command));
|
||||
}
|
||||
|
||||
function loadPluginCliRegistry(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
|
||||
) {
|
||||
function resolvePluginCliLoadContext(cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) {
|
||||
const config = cfg ?? loadConfig();
|
||||
const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
|
|
@ -51,22 +51,51 @@ function loadPluginCliRegistry(
|
|||
config: resolvedConfig,
|
||||
workspaceDir,
|
||||
logger,
|
||||
registry: loadOpenClawPlugins({
|
||||
config: resolvedConfig,
|
||||
workspaceDir,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPluginCliMetadataRegistry(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
|
||||
) {
|
||||
const context = resolvePluginCliLoadContext(cfg, env);
|
||||
return {
|
||||
...context,
|
||||
registry: await loadOpenClawPluginCliRegistry({
|
||||
config: context.config,
|
||||
workspaceDir: context.workspaceDir,
|
||||
env,
|
||||
logger,
|
||||
logger: context.logger,
|
||||
...loaderOptions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPluginCliCommandDescriptors(
|
||||
function loadPluginCliCommandRegistry(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): OpenClawPluginCliCommandDescriptor[] {
|
||||
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
|
||||
) {
|
||||
const context = resolvePluginCliLoadContext(cfg, env);
|
||||
return {
|
||||
...context,
|
||||
registry: loadOpenClawPlugins({
|
||||
config: context.config,
|
||||
workspaceDir: context.workspaceDir,
|
||||
env,
|
||||
logger: context.logger,
|
||||
...loaderOptions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPluginCliCommandDescriptors(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): Promise<OpenClawPluginCliCommandDescriptor[]> {
|
||||
try {
|
||||
const { registry } = loadPluginCliRegistry(cfg, env);
|
||||
const { registry } = await loadPluginCliMetadataRegistry(cfg, env);
|
||||
const seen = new Set<string>();
|
||||
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
|
||||
for (const entry of registry.cliRegistrars) {
|
||||
|
|
@ -91,7 +120,11 @@ export async function registerPluginCliCommands(
|
|||
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
|
||||
options?: RegisterPluginCliOptions,
|
||||
) {
|
||||
const { config, workspaceDir, logger, registry } = loadPluginCliRegistry(cfg, env, loaderOptions);
|
||||
const { config, workspaceDir, logger, registry } = loadPluginCliCommandRegistry(
|
||||
cfg,
|
||||
env,
|
||||
loaderOptions,
|
||||
);
|
||||
const mode = options?.mode ?? "eager";
|
||||
const primary = options?.primary ?? null;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createHookRunner } from "./hooks.js";
|
|||
import {
|
||||
__testing,
|
||||
clearPluginLoaderCache,
|
||||
loadOpenClawPluginCliRegistry,
|
||||
loadOpenClawPlugins,
|
||||
resolveRuntimePluginRegistry,
|
||||
} from "./loader.js";
|
||||
|
|
@ -2597,6 +2598,361 @@ module.exports = {
|
|||
expect(registry.channels).toHaveLength(expectedChannels);
|
||||
});
|
||||
|
||||
it("passes validated plugin config into non-activating CLI metadata loads", async () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "config-cli",
|
||||
filename: "config-cli.cjs",
|
||||
body: `module.exports = {
|
||||
id: "config-cli",
|
||||
register(api) {
|
||||
if (!api.pluginConfig || api.pluginConfig.token !== "ok") {
|
||||
throw new Error("missing plugin config");
|
||||
}
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "cfg",
|
||||
description: "Config-backed CLI command",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "config-cli",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
required: ["token"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = await loadOpenClawPluginCliRegistry({
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["config-cli"],
|
||||
entries: {
|
||||
"config-cli": {
|
||||
config: {
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("cfg");
|
||||
expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("uses the real channel entry in cli-metadata mode for CLI metadata capture", async () => {
|
||||
useNoBundledPlugins();
|
||||
const pluginDir = makeTempDir();
|
||||
const fullMarker = path.join(pluginDir, "full-loaded.txt");
|
||||
const modeMarker = path.join(pluginDir, "registration-mode.txt");
|
||||
const runtimeMarker = path.join(pluginDir, "runtime-set.txt");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/cli-metadata-channel",
|
||||
openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "cli-metadata-channel",
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
channels: ["cli-metadata-channel"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.cjs"),
|
||||
`const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core");
|
||||
require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
|
||||
module.exports = {
|
||||
...defineChannelPluginEntry({
|
||||
id: "cli-metadata-channel",
|
||||
name: "CLI Metadata Channel",
|
||||
description: "cli metadata channel",
|
||||
setRuntime() {
|
||||
require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8");
|
||||
},
|
||||
plugin: {
|
||||
id: "cli-metadata-channel",
|
||||
meta: {
|
||||
id: "cli-metadata-channel",
|
||||
label: "CLI Metadata Channel",
|
||||
selectionLabel: "CLI Metadata Channel",
|
||||
docsPath: "/channels/cli-metadata-channel",
|
||||
blurb: "cli metadata channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
registerCliMetadata(api) {
|
||||
require("node:fs").writeFileSync(
|
||||
${JSON.stringify(modeMarker)},
|
||||
String(api.registrationMode),
|
||||
"utf-8",
|
||||
);
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "cli-metadata-channel",
|
||||
description: "Channel CLI metadata",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
registerFull() {
|
||||
throw new Error("full channel entry should not run during CLI metadata capture");
|
||||
},
|
||||
}),
|
||||
};`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "setup-entry.cjs"),
|
||||
`throw new Error("setup entry should not load during CLI metadata capture");`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = await loadOpenClawPluginCliRegistry({
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["cli-metadata-channel"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fs.existsSync(fullMarker)).toBe(true);
|
||||
expect(fs.existsSync(runtimeMarker)).toBe(false);
|
||||
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("cli-metadata");
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
|
||||
"cli-metadata-channel",
|
||||
);
|
||||
});
|
||||
|
||||
it("collects channel CLI metadata during full plugin loads", () => {
|
||||
useNoBundledPlugins();
|
||||
const pluginDir = makeTempDir();
|
||||
const modeMarker = path.join(pluginDir, "registration-mode.txt");
|
||||
const fullMarker = path.join(pluginDir, "full-loaded.txt");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/full-cli-metadata-channel",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "full-cli-metadata-channel",
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
channels: ["full-cli-metadata-channel"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.cjs"),
|
||||
`const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core");
|
||||
module.exports = {
|
||||
...defineChannelPluginEntry({
|
||||
id: "full-cli-metadata-channel",
|
||||
name: "Full CLI Metadata Channel",
|
||||
description: "full cli metadata channel",
|
||||
plugin: {
|
||||
id: "full-cli-metadata-channel",
|
||||
meta: {
|
||||
id: "full-cli-metadata-channel",
|
||||
label: "Full CLI Metadata Channel",
|
||||
selectionLabel: "Full CLI Metadata Channel",
|
||||
docsPath: "/channels/full-cli-metadata-channel",
|
||||
blurb: "full cli metadata channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
registerCliMetadata(api) {
|
||||
require("node:fs").writeFileSync(
|
||||
${JSON.stringify(modeMarker)},
|
||||
String(api.registrationMode),
|
||||
"utf-8",
|
||||
);
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "full-cli-metadata-channel",
|
||||
description: "Full-load channel CLI metadata",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
registerFull() {
|
||||
require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
|
||||
},
|
||||
}),
|
||||
};`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["full-cli-metadata-channel"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("full");
|
||||
expect(fs.existsSync(fullMarker)).toBe(true);
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
|
||||
"full-cli-metadata-channel",
|
||||
);
|
||||
});
|
||||
|
||||
it("awaits async plugin registration when collecting CLI metadata", async () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "async-cli",
|
||||
filename: "async-cli.cjs",
|
||||
body: `module.exports = {
|
||||
id: "async-cli",
|
||||
async register(api) {
|
||||
await Promise.resolve();
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "async-cli",
|
||||
description: "Async CLI metadata",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
|
||||
const registry = await loadOpenClawPluginCliRegistry({
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["async-cli"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("async-cli");
|
||||
expect(
|
||||
registry.diagnostics.some((entry) => entry.message.includes("async registration is ignored")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("applies memory slot gating to non-bundled CLI metadata loads", async () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "memory-external",
|
||||
filename: "memory-external.cjs",
|
||||
body: `module.exports = {
|
||||
id: "memory-external",
|
||||
kind: "memory",
|
||||
register(api) {
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "memory-external",
|
||||
description: "External memory CLI metadata",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "memory-external",
|
||||
kind: "memory",
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = await loadOpenClawPluginCliRegistry({
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["memory-external"],
|
||||
slots: { memory: "memory-other" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain(
|
||||
"memory-external",
|
||||
);
|
||||
const memory = registry.plugins.find((entry) => entry.id === "memory-external");
|
||||
expect(memory?.status).toBe("disabled");
|
||||
expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"');
|
||||
});
|
||||
|
||||
it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
|||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
|
||||
import { clearPluginCommands } from "./command-registry-state.js";
|
||||
import {
|
||||
|
|
@ -145,6 +146,37 @@ export function clearPluginLoaderCache(): void {
|
|||
|
||||
const defaultLogger = () => createSubsystemLogger("plugins");
|
||||
|
||||
function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
|
||||
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
||||
return (modulePath: string) => {
|
||||
const tryNative = shouldPreferNativeJiti(modulePath);
|
||||
const aliasMap = buildPluginLoaderAliasMap(
|
||||
modulePath,
|
||||
process.argv[1],
|
||||
import.meta.url,
|
||||
options.pluginSdkResolution,
|
||||
);
|
||||
const cacheKey = JSON.stringify({
|
||||
tryNative,
|
||||
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
});
|
||||
const cached = jitiLoaders.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const loader = createJiti(import.meta.url, {
|
||||
...buildPluginLoaderJitiOptions(aliasMap),
|
||||
// Source .ts runtime shims import sibling ".js" specifiers that only exist
|
||||
// after build. Disable native loading for source entries so Jiti rewrites
|
||||
// those imports against the source graph, while keeping native dist/*.js
|
||||
// loading for the canonical built module graph.
|
||||
tryNative,
|
||||
});
|
||||
jitiLoaders.set(cacheKey, loader);
|
||||
return loader;
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
buildPluginLoaderJitiOptions,
|
||||
buildPluginLoaderAliasMap,
|
||||
|
|
@ -826,36 +858,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
}
|
||||
|
||||
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
|
||||
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
||||
const getJiti = (modulePath: string) => {
|
||||
const tryNative = shouldPreferNativeJiti(modulePath);
|
||||
// Pass loader's moduleUrl so the openclaw root can always be resolved even when
|
||||
// loading external plugins from outside the managed install directory.
|
||||
const aliasMap = buildPluginLoaderAliasMap(
|
||||
modulePath,
|
||||
process.argv[1],
|
||||
import.meta.url,
|
||||
options.pluginSdkResolution,
|
||||
);
|
||||
const cacheKey = JSON.stringify({
|
||||
tryNative,
|
||||
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
});
|
||||
const cached = jitiLoaders.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const loader = createJiti(import.meta.url, {
|
||||
...buildPluginLoaderJitiOptions(aliasMap),
|
||||
// Source .ts runtime shims import sibling ".js" specifiers that only exist
|
||||
// after build. Disable native loading for source entries so Jiti rewrites
|
||||
// those imports against the source graph, while keeping native dist/*.js
|
||||
// loading for the canonical built module graph.
|
||||
tryNative,
|
||||
});
|
||||
jitiLoaders.set(cacheKey, loader);
|
||||
return loader;
|
||||
};
|
||||
const getJiti = createPluginJitiLoader(options);
|
||||
|
||||
let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null =
|
||||
null;
|
||||
|
|
@ -1407,6 +1410,300 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
return registry;
|
||||
}
|
||||
|
||||
export async function loadOpenClawPluginCliRegistry(
|
||||
options: PluginLoadOptions = {},
|
||||
): Promise<PluginRegistry> {
|
||||
const { env, cfg, normalized, onlyPluginIds, cacheKey } = resolvePluginLoadCacheContext({
|
||||
...options,
|
||||
activate: false,
|
||||
cache: false,
|
||||
});
|
||||
const logger = options.logger ?? defaultLogger();
|
||||
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
|
||||
const getJiti = createPluginJitiLoader(options);
|
||||
const { registry, registerCli } = createPluginRegistry({
|
||||
logger,
|
||||
runtime: {} as PluginRuntime,
|
||||
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
||||
suppressGlobalCommands: true,
|
||||
});
|
||||
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir: options.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
cache: false,
|
||||
env,
|
||||
});
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
config: cfg,
|
||||
workspaceDir: options.workspaceDir,
|
||||
cache: false,
|
||||
env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
});
|
||||
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
||||
warnWhenAllowlistIsOpen({
|
||||
logger,
|
||||
pluginsEnabled: normalized.enabled,
|
||||
allow: normalized.allow,
|
||||
warningCacheKey: `${cacheKey}::cli-metadata`,
|
||||
discoverablePlugins: manifestRegistry.plugins
|
||||
.filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id))
|
||||
.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
source: plugin.source,
|
||||
origin: plugin.origin,
|
||||
})),
|
||||
});
|
||||
const provenance = buildProvenanceIndex({
|
||||
config: cfg,
|
||||
normalizedLoadPaths: normalized.loadPaths,
|
||||
env,
|
||||
});
|
||||
const manifestByRoot = new Map(
|
||||
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
||||
);
|
||||
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
|
||||
return compareDuplicateCandidateOrder({
|
||||
left,
|
||||
right,
|
||||
manifestByRoot,
|
||||
provenance,
|
||||
env,
|
||||
});
|
||||
});
|
||||
|
||||
const seenIds = new Map<string, PluginRecord["origin"]>();
|
||||
const memorySlot = normalized.slots.memory;
|
||||
let selectedMemoryPluginId: string | null = null;
|
||||
|
||||
for (const candidate of orderedCandidates) {
|
||||
const manifestRecord = manifestByRoot.get(candidate.rootDir);
|
||||
if (!manifestRecord) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = manifestRecord.id;
|
||||
if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const existingOrigin = seenIds.get(pluginId);
|
||||
if (existingOrigin) {
|
||||
const record = createPluginRecord({
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
version: manifestRecord.version,
|
||||
format: manifestRecord.format,
|
||||
bundleFormat: manifestRecord.bundleFormat,
|
||||
bundleCapabilities: manifestRecord.bundleCapabilities,
|
||||
source: candidate.source,
|
||||
rootDir: candidate.rootDir,
|
||||
origin: candidate.origin,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
enabled: false,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.status = "disabled";
|
||||
record.error = `overridden by ${existingOrigin} plugin`;
|
||||
registry.plugins.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
const enableState = resolveEffectiveEnableState({
|
||||
id: pluginId,
|
||||
origin: candidate.origin,
|
||||
config: normalized,
|
||||
rootConfig: cfg,
|
||||
enabledByDefault: manifestRecord.enabledByDefault,
|
||||
});
|
||||
const entry = normalized.entries[pluginId];
|
||||
const record = createPluginRecord({
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
version: manifestRecord.version,
|
||||
format: manifestRecord.format,
|
||||
bundleFormat: manifestRecord.bundleFormat,
|
||||
bundleCapabilities: manifestRecord.bundleCapabilities,
|
||||
source: candidate.source,
|
||||
rootDir: candidate.rootDir,
|
||||
origin: candidate.origin,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
enabled: enableState.enabled,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.kind = manifestRecord.kind;
|
||||
record.configUiHints = manifestRecord.configUiHints;
|
||||
record.configJsonSchema = manifestRecord.configSchema;
|
||||
const pushPluginLoadError = (message: string) => {
|
||||
record.status = "error";
|
||||
record.error = message;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: record.error,
|
||||
});
|
||||
};
|
||||
|
||||
if (!enableState.enabled) {
|
||||
record.status = "disabled";
|
||||
record.error = enableState.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.format === "bundle") {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manifestRecord.kind === "memory") {
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
kind: "memory",
|
||||
slot: memorySlot,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
});
|
||||
if (!memoryDecision.enabled) {
|
||||
record.enabled = false;
|
||||
record.status = "disabled";
|
||||
record.error = memoryDecision.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
if (memoryDecision.selected) {
|
||||
selectedMemoryPluginId = record.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!manifestRecord.configSchema) {
|
||||
pushPluginLoadError("missing config schema");
|
||||
continue;
|
||||
}
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
schema: manifestRecord.configSchema,
|
||||
cacheKey: manifestRecord.schemaCacheKey,
|
||||
value: entry?.config,
|
||||
});
|
||||
if (!validatedConfig.ok) {
|
||||
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
|
||||
pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
||||
const opened = openBoundaryFileSync({
|
||||
absolutePath: candidate.source,
|
||||
rootPath: pluginRoot,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: candidate.origin !== "bundled",
|
||||
skipLexicalRootCheck: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
|
||||
continue;
|
||||
}
|
||||
const safeSource = opened.path;
|
||||
fs.closeSync(opened.fd);
|
||||
|
||||
let mod: OpenClawPluginModule | null = null;
|
||||
try {
|
||||
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
|
||||
} catch (err) {
|
||||
recordPluginError({
|
||||
logger,
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
error: err,
|
||||
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
|
||||
diagnosticMessagePrefix: "failed to load plugin: ",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolvePluginModuleExport(mod);
|
||||
const definition = resolved.definition;
|
||||
const register = resolved.register;
|
||||
|
||||
if (definition?.id && definition.id !== record.id) {
|
||||
pushPluginLoadError(
|
||||
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
record.name = definition?.name ?? record.name;
|
||||
record.description = definition?.description ?? record.description;
|
||||
record.version = definition?.version ?? record.version;
|
||||
const manifestKind = record.kind as string | undefined;
|
||||
const exportKind = definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
registry.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
||||
});
|
||||
}
|
||||
record.kind = definition?.kind ?? record.kind;
|
||||
|
||||
if (typeof register !== "function") {
|
||||
logger.error(`[plugins] ${record.id} missing register/activate export`);
|
||||
pushPluginLoadError("plugin export missing register/activate");
|
||||
continue;
|
||||
}
|
||||
|
||||
const api = buildPluginApi({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
description: record.description,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
registrationMode: "cli-metadata",
|
||||
config: cfg,
|
||||
pluginConfig: validatedConfig.value,
|
||||
runtime: {} as PluginRuntime,
|
||||
logger,
|
||||
resolvePath: (input) => resolveUserPath(input),
|
||||
handlers: {
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await register(api);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
} catch (err) {
|
||||
recordPluginError({
|
||||
logger,
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
error: err,
|
||||
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
|
||||
diagnosticMessagePrefix: "plugin failed during register: ",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
function safeRealpathOrResolve(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
|
|
|
|||
|
|
@ -988,7 +988,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registerWebSearchProvider: (provider) => registerWebSearchProvider(record, provider),
|
||||
registerGatewayMethod: (method, handler, opts) =>
|
||||
registerGatewayMethod(record, method, handler, opts),
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
registerCliBackend: (backend) => registerCliBackend(record, backend),
|
||||
registerInteractiveHandler: (registration) => {
|
||||
|
|
@ -1097,6 +1096,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
|
||||
}
|
||||
: {}),
|
||||
// Allow setup-only/setup-runtime paths to surface parse-time CLI metadata
|
||||
// without opting into the wider full-registration surface.
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerChannel: (registration) => registerChannel(record, registration, registrationMode),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1674,7 +1674,7 @@ export type OpenClawPluginModule =
|
|||
| OpenClawPluginDefinition
|
||||
| ((api: OpenClawPluginApi) => void | Promise<void>);
|
||||
|
||||
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime";
|
||||
export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata";
|
||||
|
||||
/** Main registration API injected into native plugin entry files. */
|
||||
export type OpenClawPluginApi = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue