CLI: reuse loader semantics for root help descriptors

This commit is contained in:
Gustavo Madeira Santana 2026-03-29 17:31:52 -04:00
parent 14138f9ab1
commit bb86b1c7dc
No known key found for this signature in database
7 changed files with 320 additions and 394 deletions

View File

@ -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: keep root-help plugin descriptor loads non-activating while reusing loader-side plugin config validation and channel entry selection semantics. (#57294) thanks @gumadeiras.
## 2026.3.28

View File

@ -0,0 +1,33 @@
---
title: "Root Help CLI Descriptor Loader Note"
summary: "Move root-help descriptor capture onto shared loader semantics while keeping channel plugins non-activating."
author: "Gustavo Madeira Santana"
github_username: "gumadeiras"
created: "2026-03-29"
status: "implemented"
---
This note covers the follow-up on PR #57294 after review found that the first descriptor-capture approach rebuilt plugin discovery and import logic inside `src/plugins/cli.ts`.
Decision:
- Root help should be non-activating, not semantically different.
- That means `openclaw --help` should keep loader semantics for enable-state, per-plugin config, config validation, and channel/plugin selection rules.
- The loader gets a dedicated CLI-metadata snapshot mode instead of `src/plugins/cli.ts` importing plugin entries by itself.
Implementation shape:
- Add `captureCliMetadataOnly` to `loadOpenClawPlugins()`.
- In that mode, enabled channel plugins load from their real entry file but receive `registrationMode: "setup-only"` so `registerFull(...)` work does not run.
- Non-channel plugins keep the normal validated loader path, including `pluginConfig`.
- `getPluginCliCommandDescriptors()` now asks the loader for a non-activating snapshot registry and reads `registry.cliRegistrars`.
Why this replaced the earlier approach:
- The manual import loop in `src/plugins/cli.ts` dropped `api.pluginConfig`, which broke config-dependent CLI plugins.
- It also drifted away from loader behavior around channel setup entry selection and plugin registration rules.
Regression coverage added:
- A loader test that proves CLI metadata snapshot loads still receive validated `pluginConfig`.
- A loader test that proves channel CLI metadata capture uses the real channel entry in `setup-only` mode instead of the package `setupEntry`.

View File

@ -1,6 +1,3 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
@ -11,15 +8,6 @@ const mocks = vi.hoisted(() => ({
memoryListAction: vi.fn(),
loadOpenClawPlugins: vi.fn(),
applyPluginAutoEnable: vi.fn(),
discoverOpenClawPlugins: vi.fn(),
loadPluginManifestRegistry: vi.fn(),
resolveEffectiveEnableState: vi.fn(),
resolveMemorySlotDecision: vi.fn(),
createJiti: vi.fn(),
}));
vi.mock("jiti", () => ({
createJiti: (...args: unknown[]) => mocks.createJiti(...args),
}));
vi.mock("./loader.js", () => ({
@ -30,23 +18,6 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (...args: unknown[]) => mocks.applyPluginAutoEnable(...args),
}));
vi.mock("./discovery.js", () => ({
discoverOpenClawPlugins: (...args: unknown[]) => mocks.discoverOpenClawPlugins(...args),
}));
vi.mock("./manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => mocks.loadPluginManifestRegistry(...args),
}));
vi.mock("./config-state.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./config-state.js")>();
return {
...actual,
resolveEffectiveEnableState: (...args: unknown[]) => mocks.resolveEffectiveEnableState(...args),
resolveMemorySlotDecision: (...args: unknown[]) => mocks.resolveMemorySlotDecision(...args),
};
});
import { getPluginCliCommandDescriptors, registerPluginCliCommands } from "./cli.js";
function createProgram(existingCommandName?: string) {
@ -142,22 +113,6 @@ describe("registerPluginCliCommands", () => {
mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry());
mocks.applyPluginAutoEnable.mockReset();
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
mocks.discoverOpenClawPlugins.mockReset();
mocks.discoverOpenClawPlugins.mockReturnValue({
candidates: [],
diagnostics: [],
});
mocks.loadPluginManifestRegistry.mockReset();
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [],
diagnostics: [],
});
mocks.resolveEffectiveEnableState.mockReset();
mocks.resolveEffectiveEnableState.mockReturnValue({ enabled: true });
mocks.resolveMemorySlotDecision.mockReset();
mocks.resolveMemorySlotDecision.mockReturnValue({ enabled: true });
mocks.createJiti.mockReset();
mocks.createJiti.mockReturnValue(() => ({}));
});
it("skips plugin CLI registrars when commands already exist", async () => {
@ -195,165 +150,55 @@ describe("registerPluginCliCommands", () => {
);
});
it("captures channel plugin descriptors without using the activating loader", () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-cli-descriptors-"));
const pluginRoot = path.join(tempRoot, "matrix");
const source = path.join(pluginRoot, "index.ts");
mkdirSync(pluginRoot, { recursive: true });
writeFileSync(source, "export default {}");
mocks.discoverOpenClawPlugins.mockReturnValue({
candidates: [],
diagnostics: [],
});
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
it("loads root-help descriptors through the non-activating loader path", () => {
const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture();
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
mocks.loadOpenClawPlugins.mockReturnValue({
cliRegistrars: [
{
id: "matrix",
enabledByDefault: true,
format: "openclaw",
channels: ["matrix"],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: pluginRoot,
source,
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
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",
},
],
diagnostics: [],
});
const registerFull = vi.fn();
mocks.createJiti.mockReturnValue(
() =>
({
default: {
id: "matrix",
name: "Matrix",
description: "Matrix channel plugin",
channelPlugin: {},
register(api: {
registrationMode: "full" | "setup-only" | "setup-runtime";
registerChannel: (registration: unknown) => void;
registerCli: (
registrar: () => void,
opts: {
descriptors: Array<{
name: string;
description: string;
hasSubcommands: boolean;
}>;
},
) => void;
}) {
api.registerChannel({ plugin: {} });
api.registerCli(() => {}, {
descriptors: [
{
name: "matrix",
description: "Matrix channel utilities",
hasSubcommands: true,
},
],
});
if (api.registrationMode === "full") {
registerFull();
}
},
},
}) as never,
expect(getPluginCliCommandDescriptors(rawConfig)).toEqual([
{
name: "matrix",
description: "Matrix channel utilities",
hasSubcommands: true,
},
]);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: autoEnabledConfig,
activate: false,
cache: false,
captureCliMetadataOnly: true,
}),
);
try {
expect(getPluginCliCommandDescriptors({} as OpenClawConfig)).toEqual([
{
name: "matrix",
description: "Matrix channel utilities",
hasSubcommands: true,
},
]);
expect(registerFull).not.toHaveBeenCalled();
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("keeps non-channel descriptor capture in full registration mode", () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-cli-descriptors-"));
const pluginRoot = path.join(tempRoot, "memory-core");
const source = path.join(pluginRoot, "index.ts");
mkdirSync(pluginRoot, { recursive: true });
writeFileSync(source, "export default {}");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "memory-core",
enabledByDefault: true,
format: "openclaw",
kind: "memory",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
rootDir: pluginRoot,
source,
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
},
],
diagnostics: [],
});
const seenModes: string[] = [];
mocks.createJiti.mockReturnValue(
() =>
({
default: {
id: "memory-core",
name: "Memory (Core)",
description: "Memory plugin",
register(api: {
registrationMode: string;
registerCli: (
registrar: () => void,
opts: {
descriptors: Array<{
name: string;
description: string;
hasSubcommands: boolean;
}>;
},
) => void;
}) {
seenModes.push(api.registrationMode);
api.registerCli(() => {}, {
descriptors: [
{
name: "memory",
description: "Memory commands",
hasSubcommands: true,
},
],
});
},
},
}) as never,
);
try {
expect(getPluginCliCommandDescriptors({} as OpenClawConfig)).toEqual([
{
name: "memory",
description: "Memory commands",
hasSubcommands: true,
},
]);
expect(seenModes).toEqual(["full"]);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("lazy-registers descriptor-backed plugin commands on first invocation", async () => {

View File

@ -1,30 +1,14 @@
import fs from "node:fs";
import type { Command } from "commander";
import { createJiti } from "jiti";
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";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { createCapturedPluginRegistration } from "./captured-registration.js";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
} from "./config-state.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
import type { OpenClawPluginCliCommandDescriptor } from "./types.js";
import type { OpenClawPluginDefinition, OpenClawPluginModule, PluginLogger } from "./types.js";
import type { PluginLogger } from "./types.js";
const log = createSubsystemLogger("plugins");
@ -35,39 +19,6 @@ type RegisterPluginCliOptions = {
primary?: string | null;
};
function resolvePluginModuleExport(moduleExport: unknown): {
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in (moduleExport as Record<string, unknown>)
? (moduleExport as { default: unknown }).default
: moduleExport;
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const definition = resolved as OpenClawPluginDefinition;
return {
definition,
register: definition.register ?? definition.activate,
};
}
return {};
}
function isChannelPluginDefinition(definition: OpenClawPluginDefinition | undefined): boolean {
return Boolean(
definition &&
typeof definition === "object" &&
"channelPlugin" in (definition as Record<string, unknown>),
);
}
function canRegisterPluginCliLazily(entry: {
commands: string[];
descriptors: OpenClawPluginCliCommandDescriptor[];
@ -82,7 +33,10 @@ function canRegisterPluginCliLazily(entry: {
function loadPluginCliRegistry(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
loaderOptions?: Pick<
PluginLoadOptions,
"pluginSdkResolution" | "activate" | "cache" | "captureCliMetadataOnly"
>,
) {
const config = cfg ?? loadConfig();
const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config;
@ -110,125 +64,19 @@ function loadPluginCliRegistry(
};
}
// Root help only needs parse-time CLI metadata. Capture `registerCli(...)`
// against a throwaway API so plugin runtime activation does not leak into
// `openclaw --help`.
function getPluginCliCommandDescriptorsFromMetadata(
export function getPluginCliCommandDescriptors(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): OpenClawPluginCliCommandDescriptor[] {
const config = cfg ?? loadConfig();
const resolvedEnv = env ?? process.env;
const resolvedConfig = applyPluginAutoEnable({ config, env: resolvedEnv }).config;
const workspaceDir = resolveAgentWorkspaceDir(
resolvedConfig,
resolveDefaultAgentId(resolvedConfig),
);
const normalized = normalizePluginsConfig(resolvedConfig.plugins);
const discovery = discoverOpenClawPlugins({
workspaceDir,
extraPaths: normalized.loadPaths,
cache: false,
env: resolvedEnv,
});
const manifestRegistry = loadPluginManifestRegistry({
config: resolvedConfig,
workspaceDir,
cache: false,
env: resolvedEnv,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
const getJiti = (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const aliasMap = buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
undefined,
);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
try {
const { registry } = loadPluginCliRegistry(cfg, env, {
activate: false,
cache: false,
captureCliMetadataOnly: true,
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
const seen = new Set<string>();
let selectedMemoryPluginId: string | null = null;
for (const manifest of manifestRegistry.plugins) {
const enableState = resolveEffectiveEnableState({
id: manifest.id,
origin: manifest.origin,
config: normalized,
rootConfig: resolvedConfig,
enabledByDefault: manifest.enabledByDefault,
});
if (!enableState.enabled || manifest.format === "bundle") {
continue;
}
const memoryDecision = resolveMemorySlotDecision({
id: manifest.id,
kind: manifest.kind,
slot: normalized.slots.memory,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
continue;
}
if (memoryDecision.selected && manifest.kind === "memory") {
selectedMemoryPluginId = manifest.id;
}
const opened = openBoundaryFileSync({
absolutePath: manifest.source,
rootPath: manifest.rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: manifest.origin !== "bundled",
skipLexicalRootCheck: true,
});
if (!opened.ok) {
continue;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
let mod: OpenClawPluginModule | null = null;
try {
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
} catch {
continue;
}
const { definition, register } = resolvePluginModuleExport(mod);
if (typeof register !== "function") {
continue;
}
const captured = createCapturedPluginRegistration({
config: resolvedConfig,
registrationMode: isChannelPluginDefinition(definition) ? "setup-only" : "full",
});
try {
void register(captured.api);
} catch {
continue;
}
for (const entry of captured.cliRegistrars) {
const seen = new Set<string>();
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
for (const entry of registry.cliRegistrars) {
for (const descriptor of entry.descriptors) {
if (seen.has(descriptor.name)) {
continue;
@ -237,17 +85,7 @@ function getPluginCliCommandDescriptorsFromMetadata(
descriptors.push(descriptor);
}
}
}
return descriptors;
}
export function getPluginCliCommandDescriptors(
cfg?: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): OpenClawPluginCliCommandDescriptor[] {
try {
return getPluginCliCommandDescriptorsFromMetadata(cfg, env);
return descriptors;
} catch {
return [];
}

View File

@ -2597,6 +2597,193 @@ module.exports = {
expect(registry.channels).toHaveLength(expectedChannels);
});
it("passes validated plugin config into non-activating CLI metadata loads", () => {
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 = loadOpenClawPlugins({
cache: false,
activate: false,
captureCliMetadataOnly: true,
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 setup-only mode for CLI metadata capture", () => {
useNoBundledPlugins();
const pluginDir = makeTempDir();
const fullMarker = path.join(pluginDir, "full-loaded.txt");
const setupMarker = path.join(pluginDir, "setup-loaded.txt");
const modeMarker = path.join(pluginDir, "registration-mode.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"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: "cli-metadata-channel",
register(api) {
require("node:fs").writeFileSync(
${JSON.stringify(modeMarker)},
String(api.registrationMode),
"utf-8",
);
api.registerChannel({
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" },
},
});
api.registerCli(() => {}, {
descriptors: [
{
name: "cli-metadata-channel",
description: "Channel CLI metadata",
hasSubcommands: true,
},
],
});
},
};`,
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "setup-entry.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
module.exports = {
plugin: {
id: "cli-metadata-channel",
meta: {
id: "cli-metadata-channel",
label: "CLI Metadata Channel",
selectionLabel: "CLI Metadata Channel",
docsPath: "/channels/cli-metadata-channel",
blurb: "setup entry",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" }),
},
outbound: { deliveryMode: "direct" },
},
};`,
"utf-8",
);
const registry = loadOpenClawPlugins({
cache: false,
activate: false,
captureCliMetadataOnly: true,
config: {
plugins: {
load: { paths: [pluginDir] },
allow: ["cli-metadata-channel"],
},
},
});
expect(fs.existsSync(fullMarker)).toBe(true);
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("setup-only");
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
"cli-metadata-channel",
);
});
it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => {
useNoBundledPlugins();
const plugin = writePlugin({

View File

@ -88,6 +88,15 @@ export type PluginLoadOptions = {
* via package metadata because their setup entry covers the pre-listen startup surface.
*/
preferSetupRuntimeForChannelPlugins?: boolean;
/**
* Capture CLI descriptors without activating plugin runtime side effects.
*
* Enabled channel plugins still load from their real entry file so
* descriptor registration can follow the normal plugin entry contract, but
* they register in setup-only mode to keep `registerFull(...)` work out of
* parse-time help paths.
*/
captureCliMetadataOnly?: boolean;
activate?: boolean;
throwOnLoadError?: boolean;
};
@ -202,6 +211,7 @@ function buildCacheKey(params: {
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
captureCliMetadataOnly?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
pluginSdkResolution?: PluginSdkResolutionPreference;
coreGatewayMethodNames?: string[];
@ -231,12 +241,13 @@ function buildCacheKey(params: {
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
const startupChannelMode =
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
const cliMetadataMode = params.captureCliMetadataOnly === true ? "cli-metadata" : "default";
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
...params.plugins,
installs,
loadPaths,
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${cliMetadataMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
}
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
@ -269,7 +280,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
options.pluginSdkResolution !== undefined ||
options.coreGatewayHandlers !== undefined ||
options.includeSetupOnlyChannelPlugins === true ||
options.preferSetupRuntimeForChannelPlugins === true,
options.preferSetupRuntimeForChannelPlugins === true ||
options.captureCliMetadataOnly === true,
);
}
@ -280,6 +292,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
const captureCliMetadataOnly = options.captureCliMetadataOnly === true;
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
const cacheKey = buildCacheKey({
workspaceDir: options.workspaceDir,
@ -289,6 +302,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
onlyPluginIds,
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
captureCliMetadataOnly,
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
pluginSdkResolution: options.pluginSdkResolution,
coreGatewayMethodNames,
@ -300,6 +314,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
onlyPluginIds,
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
captureCliMetadataOnly,
shouldActivate: options.activate !== false,
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
cacheKey,
@ -793,6 +808,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
onlyPluginIds,
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
captureCliMetadataOnly,
shouldActivate,
cacheKey,
runtimeSubagentMode,
@ -1066,18 +1082,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
};
const registrationMode = enableState.enabled
? !validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
? captureCliMetadataOnly && manifestRecord.channels.length > 0
? "setup-only"
: !validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0
? "setup-only"
: null;
@ -1183,9 +1201,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const loadSource =
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
manifestRecord.setupSource
const loadSource = captureCliMetadataOnly
? candidate.source
: (registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
manifestRecord.setupSource
? manifestRecord.setupSource
: candidate.source;
const opened = openBoundaryFileSync({
@ -1221,6 +1240,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
if (
!captureCliMetadataOnly &&
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
manifestRecord.setupSource
) {

View File

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