From bb86b1c7dc0fa8d76643417ed7ba951d3c2b9f48 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 29 Mar 2026 17:31:52 -0400 Subject: [PATCH] CLI: reuse loader semantics for root help descriptors --- CHANGELOG.md | 1 + .../2026-03-29-root-help-cli-descriptors.md | 33 +++ src/plugins/cli.test.ts | 243 ++++-------------- src/plugins/cli.ts | 192 ++------------ src/plugins/loader.test.ts | 187 ++++++++++++++ src/plugins/loader.ts | 54 ++-- src/plugins/registry.ts | 4 +- 7 files changed, 320 insertions(+), 394 deletions(-) create mode 100644 docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d634dd47bd..6730f09d2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md b/docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md new file mode 100644 index 00000000000..f048e14551d --- /dev/null +++ b/docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md @@ -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`. diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 54e48bb92e4..760f21b2b70 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -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(); - 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 () => { diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 5223ec5ea98..80d96a8000a 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -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) - ? (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), - ); -} - function canRegisterPluginCliLazily(entry: { commands: string[]; descriptors: OpenClawPluginCliCommandDescriptor[]; @@ -82,7 +33,10 @@ function canRegisterPluginCliLazily(entry: { function loadPluginCliRegistry( cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, - loaderOptions?: Pick, + 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>(); - 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(); - 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(); + 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 []; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index efa3d25168c..91843c3d2d4 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2c0c188747a..0d3669c49e8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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 ) { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 20a5d6d9731..45a21056c80 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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), }, });