From ebd23631ae8d92f0ff4ebd14e663c355736751b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 05:43:35 -0700 Subject: [PATCH] perf(cli): build root help from plugin metadata (#124562) --- docs/plugins/manifest.md | 20 +++ docs/plugins/sdk-entrypoints.md | 4 +- extensions/browser/openclaw.plugin.json | 7 + extensions/codex/openclaw.plugin.json | 7 + extensions/google-meet/openclaw.plugin.json | 7 + extensions/matrix/openclaw.plugin.json | 7 + extensions/memory-core/openclaw.plugin.json | 7 + .../memory-lancedb/openclaw.plugin.json | 7 + extensions/memory-wiki/openclaw.plugin.json | 7 + extensions/oc-path/openclaw.plugin.json | 7 + extensions/onepassword/openclaw.plugin.json | 7 + extensions/policy/openclaw.plugin.json | 7 + extensions/qa-lab/openclaw.plugin.json | 7 + extensions/reef/openclaw.plugin.json | 7 + .../teams-meetings/openclaw.plugin.json | 7 + extensions/vault/openclaw.plugin.json | 7 + extensions/voice-call/openclaw.plugin.json | 7 + extensions/workboard/openclaw.plugin.json | 7 + extensions/zoom-meetings/openclaw.plugin.json | 7 + src/cli/program/root-help.test.ts | 2 +- src/cli/program/root-help.ts | 2 +- src/commands/status.cold-imports.test.ts | 23 +++ src/commands/status.command.text-runtime.ts | 2 +- src/commands/status.scan.ts | 5 +- src/plugins/activation-planner.test.ts | 8 +- src/plugins/activation-planner.ts | 8 + src/plugins/cli-root-descriptors.ts | 96 ++++++++++++ src/plugins/cli.test.ts | 139 +++++++++++++----- src/plugins/cli.ts | 18 +-- src/plugins/manifest-registry.ts | 2 + src/plugins/manifest-setup-normalizers.ts | 32 ++++ src/plugins/manifest-types.ts | 9 ++ src/plugins/manifest.json5-tolerance.test.ts | 12 ++ src/plugins/manifest.ts | 1 + src/plugins/status-compatibility.ts | 28 ++++ src/plugins/status.ts | 38 ++--- 36 files changed, 480 insertions(+), 88 deletions(-) create mode 100644 src/commands/status.cold-imports.test.ts create mode 100644 src/plugins/cli-root-descriptors.ts create mode 100644 src/plugins/status-compatibility.ts diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 639d0bd70ed6..660c39bfdac4 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -30,6 +30,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m - plugin identity, config validation, and config UI hints - auth, onboarding, and setup metadata (alias, auto-enable, provider env vars, auth choices) - activation hints for control-plane surfaces +- root CLI command names, descriptions, and subcommand markers (`cliCommands`) - shorthand model-family ownership - static capability-ownership snapshots (`contracts`) - dashboard widget data bindings and action verbs @@ -157,6 +158,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m | `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. | | `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. | | `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. | +| `cliCommands` | No | `object[]` | Root CLI commands shown in `openclaw --help` before plugin code loads. Each row requires `name`, `description`, and `hasSubcommands`. | | `providerUsageAuthEnvVars` | No | `Record` | Usage/billing-only provider credentials. OpenClaw uses these names for usage discovery and secret scrubbing but never for inference auth. | | `providerAuthAliases` | No | `Record` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. | | `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | @@ -435,6 +437,24 @@ expose `appGuidedSetup.detectAvailability` to mark its setup choice as detected when the local service is reachable but no model qualifies for automatic setup. The availability probe is also read-only. +## cliCommands reference + +Declare every plugin-owned root command in `cliCommands` so root help and command-owner routing stay metadata-only: + +```json +{ + "cliCommands": [ + { + "name": "example", + "description": "Manage the example integration", + "hasSubcommands": true + } + ] +} +``` + +The manifest row is the canonical help text. Register the same command at runtime with `api.registerCli(..., { descriptors: [...] })`; runtime descriptors may additionally provide `machineOutput`. Nested commands such as `openclaw nodes ` are not root commands and do not belong in `cliCommands`. + ## commandAliases reference Use `commandAliases` when a plugin owns a runtime command name that users may mistakenly put in `plugins.allow` or try to run as a root CLI command. OpenClaw uses this metadata for diagnostics without importing plugin runtime code. diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index b9f0539e1c8b..a0945c2f6353 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -277,7 +277,9 @@ CLI registration: parse tree. Descriptor names must match letters, numbers, hyphen, and underscore, starting with a letter or number; OpenClaw rejects other shapes and strips terminal control sequences from descriptions before - rendering help. Cover every top-level command root the registrar exposes. + rendering help. Cover every top-level command root the registrar exposes, + and declare the same name, description, and subcommand marker in the + plugin manifest's `cliCommands` field so root help does not import plugin code. `commands` alone stays on the eager compatibility path. - Root descriptors may define a synchronous, pure `machineOutput({ argv, stdoutIsTTY })` resolver for JSON, JSONL, or other diff --git a/extensions/browser/openclaw.plugin.json b/extensions/browser/openclaw.plugin.json index 4a68181ff743..c35b3940a4db 100644 --- a/extensions/browser/openclaw.plugin.json +++ b/extensions/browser/openclaw.plugin.json @@ -1,5 +1,12 @@ { "id": "browser", + "cliCommands": [ + { + "name": "browser", + "description": "Manage OpenClaw's dedicated browser (Chrome/Chromium)", + "hasSubcommands": true + } + ], "enabledByDefault": true, "activation": { "onStartup": true, diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 14218c8bddc7..c3dc0ae48b73 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -16,6 +16,13 @@ ], "name": "Codex", "description": "Codex app-server harness and native session catalog.", + "cliCommands": [ + { + "name": "codex", + "description": "Inspect and branch from Codex sessions through the Gateway", + "hasSubcommands": true + } + ], "contracts": { "mediaUnderstandingProviders": ["codex"], "migrationProviders": ["codex"], diff --git a/extensions/google-meet/openclaw.plugin.json b/extensions/google-meet/openclaw.plugin.json index fcccfaa07b1c..a29853162fc9 100644 --- a/extensions/google-meet/openclaw.plugin.json +++ b/extensions/google-meet/openclaw.plugin.json @@ -5,6 +5,13 @@ }, "name": "Google Meet", "description": "OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.", + "cliCommands": [ + { + "name": "googlemeet", + "description": "Join and manage Google Meet calls", + "hasSubcommands": true + } + ], "icon": "https://cdn.simpleicons.org/googlemeet", "enabledByDefault": true, "commandAliases": [{ "name": "googlemeet" }], diff --git a/extensions/matrix/openclaw.plugin.json b/extensions/matrix/openclaw.plugin.json index c1a355f50abe..e0838aa39afd 100644 --- a/extensions/matrix/openclaw.plugin.json +++ b/extensions/matrix/openclaw.plugin.json @@ -6,6 +6,13 @@ }, "name": "Matrix", "description": "OpenClaw Matrix channel plugin for rooms and direct messages.", + "cliCommands": [ + { + "name": "matrix", + "description": "Manage Matrix accounts, verification, devices, and profile state", + "hasSubcommands": true + } + ], "icon": "https://cdn.simpleicons.org/matrix", "commandAliases": [{ "name": "matrix" }], "activation": { diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index 1624c34e8292..3d432fc528d3 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -4,6 +4,13 @@ "stateMigrations": true }, "name": "OpenClaw Memory", + "cliCommands": [ + { + "name": "memory", + "description": "Search, inspect, and reindex memory files", + "hasSubcommands": true + } + ], "activation": { "onStartup": false }, diff --git a/extensions/memory-lancedb/openclaw.plugin.json b/extensions/memory-lancedb/openclaw.plugin.json index 5921f5d6f60f..4a2c766399aa 100644 --- a/extensions/memory-lancedb/openclaw.plugin.json +++ b/extensions/memory-lancedb/openclaw.plugin.json @@ -5,6 +5,13 @@ }, "name": "Memory LanceDB", "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall, auto-capture, and vector search.", + "cliCommands": [ + { + "name": "ltm", + "description": "Inspect and query LanceDB-backed memory", + "hasSubcommands": true + } + ], "catalog": { "featured": true, "order": 70 }, "commandAliases": [{ "name": "ltm" }], "activation": { diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 3e9979654ff5..620e1064f7c0 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -9,6 +9,13 @@ }, "name": "Memory Wiki", "description": "Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.", + "cliCommands": [ + { + "name": "wiki", + "description": "Inspect and initialize the memory wiki vault", + "hasSubcommands": true + } + ], "catalog": { "featured": true, "order": 30 }, "contracts": { "tools": ["wiki_apply", "wiki_get", "wiki_lint", "wiki_search", "wiki_status"] diff --git a/extensions/oc-path/openclaw.plugin.json b/extensions/oc-path/openclaw.plugin.json index 496109336482..309302d67eb0 100644 --- a/extensions/oc-path/openclaw.plugin.json +++ b/extensions/oc-path/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "oc-path", "name": "OC Path", "description": "Adds the openclaw path CLI for oc:// workspace file addressing.", + "cliCommands": [ + { + "name": "path", + "description": "Inspect and edit workspace files via oc:// paths", + "hasSubcommands": true + } + ], "activation": { "onStartup": false, "onCommands": ["path"] diff --git a/extensions/onepassword/openclaw.plugin.json b/extensions/onepassword/openclaw.plugin.json index eecc3ee30afe..78dfafa27ae8 100644 --- a/extensions/onepassword/openclaw.plugin.json +++ b/extensions/onepassword/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "onepassword", "name": "1Password", "description": "1Password SecretRef resolver and curated agent broker with approval policy and SQLite audit history.", + "cliCommands": [ + { + "name": "onepassword", + "description": "Manage the 1Password integration", + "hasSubcommands": true + } + ], "activation": { "onStartup": false, "onCommands": ["onepassword"], diff --git a/extensions/policy/openclaw.plugin.json b/extensions/policy/openclaw.plugin.json index 23ad9ab7b01b..88e49cf1aca6 100644 --- a/extensions/policy/openclaw.plugin.json +++ b/extensions/policy/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "policy", "name": "Policy", "description": "Adds policy-backed doctor checks for workspace conformance.", + "cliCommands": [ + { + "name": "policy", + "description": "Check policy requirements and emit audit evidence", + "hasSubcommands": true + } + ], "activation": { "onStartup": true, "onCommands": ["doctor", "policy"] diff --git a/extensions/qa-lab/openclaw.plugin.json b/extensions/qa-lab/openclaw.plugin.json index cb949d236fb3..475eb0d8a0f5 100644 --- a/extensions/qa-lab/openclaw.plugin.json +++ b/extensions/qa-lab/openclaw.plugin.json @@ -1,6 +1,13 @@ { "id": "qa-lab", "description": "OpenClaw QA lab plugin with private debugger UI and scenario runner.", + "cliCommands": [ + { + "name": "qa", + "description": "Run QA scenarios and launch the private QA debugger UI", + "hasSubcommands": true + } + ], "activation": { "onStartup": false }, diff --git a/extensions/reef/openclaw.plugin.json b/extensions/reef/openclaw.plugin.json index fcee8cef83bb..e137d110bac8 100644 --- a/extensions/reef/openclaw.plugin.json +++ b/extensions/reef/openclaw.plugin.json @@ -6,6 +6,13 @@ }, "name": "Reef", "description": "Guarded end-to-end encrypted claw channel", + "cliCommands": [ + { + "name": "reef", + "description": "Register on a Reef relay and manage guarded claw-to-claw friendships", + "hasSubcommands": true + } + ], "activation": { "onStartup": true, "onCommands": [ diff --git a/extensions/teams-meetings/openclaw.plugin.json b/extensions/teams-meetings/openclaw.plugin.json index 84fba76d2856..6fe057181489 100644 --- a/extensions/teams-meetings/openclaw.plugin.json +++ b/extensions/teams-meetings/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "teams-meetings", "name": "Microsoft Teams meetings", "description": "Join Microsoft Teams meetings as a Chrome browser guest.", + "cliCommands": [ + { + "name": "teamsmeetings", + "description": "Join and manage Microsoft Teams meeting guests", + "hasSubcommands": true + } + ], "icon": "https://res.cdn.office.net/files/fabric-cdn-prod_20230815.001/assets/brand-icons/product/svg/teams_48x1.svg", "enabledByDefault": true, "commandAliases": [{ "name": "teamsmeetings" }], diff --git a/extensions/vault/openclaw.plugin.json b/extensions/vault/openclaw.plugin.json index 9b55d5b998cb..9b9281390ff0 100644 --- a/extensions/vault/openclaw.plugin.json +++ b/extensions/vault/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "vault", "name": "Vault", "description": "HashiCorp Vault SecretRef provider integration.", + "cliCommands": [ + { + "name": "vault", + "description": "Manage the Vault SecretRef provider integration", + "hasSubcommands": true + } + ], "activation": { "onStartup": false, "onCommands": ["vault"] diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index a660657ef7b9..78004f86fd2d 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -6,6 +6,13 @@ }, "name": "Voice Call", "description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.", + "cliCommands": [ + { + "name": "voicecall", + "description": "Voice call utilities", + "hasSubcommands": true + } + ], "skills": ["./skills"], "commandAliases": [{ "name": "voicecall" }], "activation": { diff --git a/extensions/workboard/openclaw.plugin.json b/extensions/workboard/openclaw.plugin.json index 7c051e7157ce..75ed1aac177e 100644 --- a/extensions/workboard/openclaw.plugin.json +++ b/extensions/workboard/openclaw.plugin.json @@ -1,5 +1,12 @@ { "id": "workboard", + "cliCommands": [ + { + "name": "workboard", + "description": "Manage Workboard cards and worker dispatch", + "hasSubcommands": true + } + ], "doctorContract": { "stateMigrations": true }, diff --git a/extensions/zoom-meetings/openclaw.plugin.json b/extensions/zoom-meetings/openclaw.plugin.json index 87faa2c5761c..16fbe4cf2e80 100644 --- a/extensions/zoom-meetings/openclaw.plugin.json +++ b/extensions/zoom-meetings/openclaw.plugin.json @@ -2,6 +2,13 @@ "id": "zoom-meetings", "name": "Zoom meetings", "description": "Join Zoom meetings as a Chrome browser guest.", + "cliCommands": [ + { + "name": "zoommeetings", + "description": "Join and manage Zoom meeting guests", + "hasSubcommands": true + } + ], "icon": "https://cdn.simpleicons.org/zoom", "enabledByDefault": true, "commandAliases": [{ "name": "zoommeetings" }], diff --git a/src/cli/program/root-help.test.ts b/src/cli/program/root-help.test.ts index f2af270bea8f..28e20f2a59db 100644 --- a/src/cli/program/root-help.test.ts +++ b/src/cli/program/root-help.test.ts @@ -48,7 +48,7 @@ vi.mock("./subcli-descriptors.js", () => ({ getSubCliCommandsWithSubcommands: () => ["config"], })); -vi.mock("../../plugins/cli.js", () => ({ +vi.mock("../../plugins/cli-root-descriptors.js", () => ({ getPluginCliCommandDescriptors: (...args: [unknown?, unknown?, unknown?]) => getPluginCliCommandDescriptorsMock(...args), })); diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index 2c7b58c04e79..20759d61d24d 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,7 +1,7 @@ // Root help renderer that combines core, sub-CLI, and optional plugin command descriptors. import { Command } from "commander"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { getPluginCliCommandDescriptors } from "../../plugins/cli.js"; +import { getPluginCliCommandDescriptors } from "../../plugins/cli-root-descriptors.js"; import type { PluginLoadOptions } from "../../plugins/loader.js"; import { VERSION } from "../../version.js"; import { diff --git a/src/commands/status.cold-imports.test.ts b/src/commands/status.cold-imports.test.ts new file mode 100644 index 000000000000..17a22dbb9088 --- /dev/null +++ b/src/commands/status.cold-imports.test.ts @@ -0,0 +1,23 @@ +// Default status imports must not pull in the broad plugin diagnostics/runtime graph. +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("status cold imports", () => { + afterEach(() => { + vi.doUnmock("../plugins/status.js"); + vi.resetModules(); + }); + + it("keeps broad plugin status code behind the detailed status boundary", async () => { + vi.doMock("../plugins/status.js", () => { + throw new Error("default status must not import broad plugin diagnostics"); + }); + + const [scan, textRuntime] = await Promise.all([ + import("./status.scan.js"), + import("./status.command.text-runtime.js"), + ]); + + expect(scan.scanStatus).toBeTypeOf("function"); + expect(textRuntime.formatPluginCompatibilityNotice).toBeTypeOf("function"); + }); +}); diff --git a/src/commands/status.command.text-runtime.ts b/src/commands/status.command.text-runtime.ts index 0790327da55f..0a70ffac2c4c 100644 --- a/src/commands/status.command.text-runtime.ts +++ b/src/commands/status.command.text-runtime.ts @@ -13,7 +13,7 @@ export { export { formatPluginCompatibilityNotice, summarizePluginCompatibility, -} from "../plugins/status.js"; +} from "../plugins/status-compatibility.js"; export { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js"; export { theme } from "../../packages/terminal-core/src/theme.js"; export { formatHealthChannelLines } from "./health-format.js"; diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 54dbf6536540..3681b6f7c258 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -3,7 +3,6 @@ import { withProgress } from "../cli/progress.js"; import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; -import { buildPluginCompatibilitySnapshotNotices } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; import { executeStatusScanFromOverview } from "./status.scan-execute.ts"; import { resolveStatusMemoryStatusSnapshot } from "./status.scan-memory.ts"; @@ -80,7 +79,9 @@ export async function scanStatus( progress.setLabel("Checking plugins…"); const pluginCompatibility = opts.all - ? buildPluginCompatibilitySnapshotNotices({ config: overview.cfg }) + ? await import("../plugins/status.js").then(({ buildPluginCompatibilitySnapshotNotices }) => + buildPluginCompatibilitySnapshotNotices({ config: overview.cfg }), + ) : []; progress.tick(); diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index ee66108515a6..3d05d5cf89ad 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -45,7 +45,13 @@ describe("activation planner", () => { }, { id: "browser", - commandAliases: [{ name: "browser" }], + cliCommands: [ + { + name: "browser", + description: "Manage the browser", + hasSubcommands: true, + }, + ], providers: [], channels: [], cliBackends: [], diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index c3c6923e89e2..bdee8cd4c623 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -35,6 +35,7 @@ type PluginActivationPlannerHintReason = type PluginActivationPlannerManifestReason = | "manifest-channel-owner" + | "manifest-cli-command-owner" | "manifest-command-alias" | "manifest-hook-owner" | "manifest-provider-owner" @@ -195,6 +196,13 @@ function listCommandTriggerReasons( listHasNormalizedValue(plugin.activation?.onCommands, command, normalizeCommandId) ? "activation-command-hint" : null, + listHasNormalizedValue( + plugin.cliCommands?.map((descriptor) => descriptor.name), + command, + normalizeCommandId, + ) + ? "manifest-cli-command-owner" + : null, listHasNormalizedValue( (plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name), command, diff --git a/src/plugins/cli-root-descriptors.ts b/src/plugins/cli-root-descriptors.ts new file mode 100644 index 000000000000..a13565aae859 --- /dev/null +++ b/src/plugins/cli-root-descriptors.ts @@ -0,0 +1,96 @@ +/** Resolves root CLI help from process-stable manifests before plugin code loads. */ +import { collectUniqueCommandDescriptors } from "../cli/program/command-descriptor-utils.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginCliLoaderOptions } from "./cli-registry-loader.js"; +import { normalizePluginsConfig, resolveMemorySlotDecision } from "./config-state.js"; +import { isInstalledPluginEnabled } from "./installed-plugin-index.js"; +import { validatePluginConfig } from "./loader-shared.js"; +import { normalizePluginPolicyId } from "./plugin-policy-id.js"; +import { + buildPluginRuntimeLoadOptions, + resolvePluginRuntimeLoadContext, +} from "./runtime/load-context.js"; +import { hasKind } from "./slots.js"; +import type { OpenClawPluginCliRootCommandDescriptor, PluginLogger } from "./types.js"; + +const quietLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +} satisfies PluginLogger; + +export async function getPluginCliCommandDescriptors( + cfg?: OpenClawConfig, + env?: NodeJS.ProcessEnv, + loaderOptions?: PluginCliLoaderOptions, +): Promise { + try { + const context = resolvePluginRuntimeLoadContext({ config: cfg, env, logger: quietLogger }); + const snapshot = context.metadataSnapshot; + if (!snapshot) { + return []; + } + const legacyExternalPluginIds: string[] = []; + const descriptorGroups: OpenClawPluginCliRootCommandDescriptor[][] = []; + const seenPluginIds = new Set(); + let selectedMemoryPluginId: string | null = null; + const memorySlot = context.config.plugins?.slots?.memory; + const normalizedConfig = normalizePluginsConfig(context.config.plugins); + + for (const plugin of snapshot.plugins) { + if (seenPluginIds.has(plugin.id)) { + continue; + } + seenPluginIds.add(plugin.id); + if (!isInstalledPluginEnabled(snapshot.index, plugin.id, context.config)) { + continue; + } + const pluginConfig = normalizedConfig.entries[normalizePluginPolicyId(plugin.id)]?.config; + if ( + !validatePluginConfig({ + schema: plugin.configSchema, + cacheKey: plugin.schemaCacheKey, + value: pluginConfig, + }).ok + ) { + continue; + } + const memoryDecision = resolveMemorySlotDecision({ + id: plugin.id, + kind: plugin.kind, + slot: memorySlot, + selectedId: selectedMemoryPluginId, + }); + if (!memoryDecision.enabled) { + continue; + } + if (memoryDecision.selected && hasKind(plugin.kind, "memory")) { + selectedMemoryPluginId = plugin.id; + } + if (plugin.cliCommands) { + descriptorGroups.push(plugin.cliCommands); + } else if (plugin.origin !== "bundled" && plugin.format !== "bundle") { + legacyExternalPluginIds.push(plugin.id); + } + } + + if (legacyExternalPluginIds.length > 0) { + const { loadOpenClawPluginCliRegistry } = await import("./loader.js"); + const registry = await loadOpenClawPluginCliRegistry( + buildPluginRuntimeLoadOptions(context, { + ...loaderOptions, + onlyPluginIds: legacyExternalPluginIds, + }), + ); + descriptorGroups.push( + ...registry.cliRegistrars + .filter((entry) => (entry.parentPath ?? []).length === 0) + .map((entry) => entry.descriptors), + ); + } + return collectUniqueCommandDescriptors(descriptorGroups); + } catch { + return []; + } +} diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 223c96e55e8f..d6b66a126214 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -39,6 +39,7 @@ vi.mock("../config/io.plugin-metadata.js", () => ({ })); vi.mock("./plugin-metadata-snapshot.js", () => ({ + isPluginMetadataSnapshotCompatible: () => true, rebasePluginMetadataSnapshotManifestRegistry: (snapshot: T) => snapshot, resolvePluginMetadataSnapshot: (...args: unknown[]) => mocks.resolvePluginMetadataSnapshot(...args), @@ -117,6 +118,53 @@ function createAutoEnabledCliFixture() { return { rawConfig, autoEnabledConfig }; } +function createCliMetadataSnapshot() { + const plugin = { + id: "matrix", + origin: "bundled", + format: "openclaw", + cliCommands: [ + { + name: "matrix", + description: "Matrix channel utilities", + hasSubcommands: true, + }, + ], + }; + return { + policyHash: "test", + index: { + installRecords: {}, + plugins: [{ pluginId: "matrix", enabled: true, enabledByDefault: true, origin: "bundled" }], + }, + manifestRegistry: { plugins: [plugin], diagnostics: [] }, + plugins: [plugin], + diagnostics: [], + byPluginId: new Map([[plugin.id, plugin]]), + owners: {}, + }; +} + +function createLegacyExternalCliMetadataSnapshot() { + const plugin = { + id: "legacy-cli", + origin: "config", + format: "openclaw", + }; + return { + policyHash: "test", + index: { + installRecords: {}, + plugins: [{ pluginId: plugin.id, enabled: true, origin: plugin.origin }], + }, + manifestRegistry: { plugins: [plugin], diagnostics: [] }, + plugins: [plugin], + diagnostics: [], + byPluginId: new Map([[plugin.id, plugin]]), + owners: {}, + }; +} + function getMockCallObject(mock: ReturnType, callIndex = 0, argIndex = 0) { const value = mock.mock.calls[callIndex]?.[argIndex]; if (!value || typeof value !== "object") { @@ -288,7 +336,7 @@ describe("registerPluginCliCommands", () => { expect(registerOptions.config).toBe(autoEnabledConfig); }); - it("loads root-help descriptors through the dedicated non-activating CLI collector", async () => { + it("loads root-help descriptors from manifests without entering the plugin module loader", async () => { const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, @@ -297,36 +345,7 @@ describe("registerPluginCliCommands", () => { demo: ["demo configured"], }, }); - 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", - }, - ], - }); + mocks.resolvePluginMetadataSnapshot.mockReturnValue(createCliMetadataSnapshot()); await expect(getPluginCliCommandDescriptors(rawConfig)).resolves.toEqual([ { @@ -335,12 +354,15 @@ describe("registerPluginCliCommands", () => { hasSubcommands: true, }, ]); - const registryOptions = getMockCallObject(mocks.loadOpenClawPluginCliRegistry); - expect(registryOptions.config).toBe(autoEnabledConfig); - expect(registryOptions.activationSourceConfig).toBe(rawConfig); - expect(registryOptions.autoEnabledReasons).toEqual({ - demo: ["demo configured"], - }); + const { renderRootHelpText } = await import("../cli/program/root-help.js"); + const help = await renderRootHelpText({ config: rawConfig }); + expect(help).toContain("matrix *"); + expect(help).toContain("Matrix channel utilities"); + expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled(); + expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith( + expect.objectContaining({ config: rawConfig }), + ); + expect(autoEnabledConfig.plugins?.entries?.demo?.enabled).toBe(true); }); it("keeps root-help descriptor load failures quiet", async () => { @@ -352,14 +374,55 @@ describe("registerPluginCliCommands", () => { logger.error?.("[plugins] stale failed to load from /tmp/stale: boom"); throw new Error("boom"); }); + mocks.resolvePluginMetadataSnapshot.mockReturnValue(createLegacyExternalCliMetadataSnapshot()); await expect( - getPluginCliCommandDescriptors({ plugins: { entries: { stale: {} } } } as OpenClawConfig), + getPluginCliCommandDescriptors({ + plugins: { entries: { "legacy-cli": { enabled: true } } }, + } as OpenClawConfig), ).resolves.toEqual([]); expect(stderrWrite).not.toHaveBeenCalled(); }); + it("preserves root help for external plugins without manifest CLI descriptors", async () => { + const config = { + plugins: { + entries: { "legacy-cli": { enabled: true } }, + }, + } as OpenClawConfig; + mocks.resolvePluginMetadataSnapshot.mockReturnValue(createLegacyExternalCliMetadataSnapshot()); + mocks.loadOpenClawPluginCliRegistry.mockResolvedValue({ + cliRegistrars: [ + { + pluginId: "legacy-cli", + register: vi.fn(), + parentPath: [], + commands: ["legacy"], + descriptors: [ + { + name: "legacy", + description: "Legacy external command", + hasSubcommands: true, + }, + ], + source: "/tmp/legacy-cli/index.js", + }, + ], + }); + + await expect(getPluginCliCommandDescriptors(config)).resolves.toEqual([ + { + name: "legacy", + description: "Legacy external command", + hasSubcommands: true, + }, + ]); + expect(getMockCallObject(mocks.loadOpenClawPluginCliRegistry).onlyPluginIds).toEqual([ + "legacy-cli", + ]); + }); + it("keeps runtime CLI command registration on the full plugin loader for legacy channel plugins", async () => { const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); mocks.applyPluginAutoEnable.mockReturnValue({ diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index cf4074cdc39d..11e8244c646d 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -4,12 +4,11 @@ import { getRuntimeConfigSnapshot, readConfigFileSnapshot } from "../config/conf import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createPluginCliLogger, - loadPluginCliDescriptors, loadPluginCliRegistrationEntriesWithDefaults, type PluginCliLoaderOptions, } from "./cli-registry-loader.js"; import { registerPluginCliCommandGroups } from "./register-plugin-cli-command-groups.js"; -import type { OpenClawPluginCliRootCommandDescriptor, PluginLogger } from "./types.js"; +export { getPluginCliCommandDescriptors } from "./cli-root-descriptors.js"; type PluginCliRegistrationMode = "eager" | "lazy"; @@ -37,13 +36,6 @@ const logger = createPluginCliLogger(); const loaderOptionIds = new WeakMap(); let nextLoaderOptionId = 1; -const quietDescriptorLogger = { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, -} satisfies PluginLogger; - function stableJsonKey(value: unknown): string { if (value === undefined) { return "undefined"; @@ -88,14 +80,6 @@ export const loadValidatedConfigForPluginRegistration = async (options?: { return getRuntimeConfigSnapshot() ?? snapshot.runtimeConfig; }; -export async function getPluginCliCommandDescriptors( - cfg?: OpenClawConfig, - env?: NodeJS.ProcessEnv, - loaderOptions?: PluginCliLoaderOptions, -): Promise { - return loadPluginCliDescriptors({ cfg, env, loaderOptions, logger: quietDescriptorLogger }); -} - export async function registerPluginCliCommands( program: Command, cfg?: OpenClawConfig, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 59681d080f44..6db9c759eda2 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -248,6 +248,7 @@ export type PluginManifestRecord = { syntheticAuthRefs?: string[]; nonSecretAuthMarkers?: string[]; commandAliases?: PluginManifestCommandAlias[]; + cliCommands?: PluginManifest["cliCommands"]; providerUsageAuthEnvVars?: Record; providerAuthAliases?: Record; providerAuthChoices?: PluginManifest["providerAuthChoices"]; @@ -605,6 +606,7 @@ function buildRecord(params: { syntheticAuthRefs: params.manifest.syntheticAuthRefs ?? [], nonSecretAuthMarkers: params.manifest.nonSecretAuthMarkers ?? [], commandAliases: params.manifest.commandAliases, + cliCommands: params.manifest.cliCommands, providerUsageAuthEnvVars: params.manifest.providerUsageAuthEnvVars, providerAuthAliases: params.manifest.providerAuthAliases, providerAuthChoices: params.manifest.providerAuthChoices, diff --git a/src/plugins/manifest-setup-normalizers.ts b/src/plugins/manifest-setup-normalizers.ts index a224e0575f42..35e6b5bd9e08 100644 --- a/src/plugins/manifest-setup-normalizers.ts +++ b/src/plugins/manifest-setup-normalizers.ts @@ -1,6 +1,10 @@ import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js"; import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js"; +import { + normalizeCommandDescriptorName, + sanitizeCommandDescriptorDescription, +} from "../cli/program/command-descriptor-utils.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; import { isRecord } from "../utils.js"; @@ -9,6 +13,7 @@ import type { PluginManifestActivationCapability, PluginManifestChannelCommandDefaults, PluginManifestChannelConfig, + PluginManifestCliCommand, PluginManifestDashboard, PluginManifestDashboardActionVerb, PluginManifestDashboardDataBinding, @@ -56,6 +61,33 @@ export function normalizeManifestActivation(value: unknown): PluginManifestActiv return Object.keys(activation).length > 0 ? activation : undefined; } +export function normalizeManifestCliCommands( + value: unknown, +): PluginManifestCliCommand[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const seen = new Set(); + const commands: PluginManifestCliCommand[] = []; + for (const entry of value) { + if ( + !isRecord(entry) || + typeof entry.name !== "string" || + typeof entry.description !== "string" + ) { + continue; + } + const name = normalizeCommandDescriptorName(entry.name); + const description = sanitizeCommandDescriptorDescription(entry.description); + if (!name || !description || typeof entry.hasSubcommands !== "boolean" || seen.has(name)) { + continue; + } + seen.add(name); + commands.push({ name, description, hasSubcommands: entry.hasSubcommands }); + } + return commands; +} + const MANIFEST_DEFAULT_ENABLEMENT_PLATFORMS = new Set([ "aix", "android", diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 2fc2db1a475e..8fa393d2454f 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -179,6 +179,13 @@ export type PluginManifestActivation = { onCapabilities?: PluginManifestActivationCapability[]; }; +/** Root CLI command metadata available before plugin code is imported. */ +export type PluginManifestCliCommand = { + name: string; + description: string; + hasSubcommands: boolean; +}; + export type PluginManifestDefaultPlatform = NodeJS.Platform; export type PluginManifestSetupProvider = { @@ -384,6 +391,8 @@ export type PluginManifest = { * config diagnostics before runtime loads. */ commandAliases?: PluginManifestCommandAlias[]; + /** Root commands advertised by help and activation planning before runtime loads. */ + cliCommands?: PluginManifestCliCommand[]; /** Usage/billing credentials excluded from inference auth but included in secret scrubbing. */ providerUsageAuthEnvVars?: Record; /** Provider ids that should reuse another provider id for auth lookup. */ diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index 64705d0e9c46..787536f93fe3 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -342,6 +342,11 @@ describe("loadPluginManifest JSON5 tolerance", () => { onConfigPaths: ["browser", ""], onCapabilities: ["provider", "tool", "wat"] }, + cliCommands: [ + { name: "models", description: "Inspect provider models", hasSubcommands: true }, + { name: "bad command", description: "ignored", hasSubcommands: false }, + { name: "models", description: "duplicate", hasSubcommands: false } + ], setup: { providers: [ { id: "openai", authMethods: ["api-key", ""], envVars: ["OPENAI_API_KEY", ""] }, @@ -366,6 +371,13 @@ describe("loadPluginManifest JSON5 tolerance", () => { onConfigPaths: ["browser"], onCapabilities: ["provider", "tool"], }); + expect(result.manifest.cliCommands).toEqual([ + { + name: "models", + description: "Inspect provider models", + hasSubcommands: true, + }, + ]); expect(result.manifest.setup).toEqual({ providers: [ { diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 82106dfba5ef..7c69d83cc477 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -261,6 +261,7 @@ export function loadPluginManifest( syntheticAuthRefs: normalizeTrimmedStringList(raw.syntheticAuthRefs), nonSecretAuthMarkers: normalizeTrimmedStringList(raw.nonSecretAuthMarkers), commandAliases: normalizeManifestCommandAliases(raw.commandAliases), + cliCommands: setupNormalizers.normalizeManifestCliCommands(raw.cliCommands), providerUsageAuthEnvVars: capabilityNormalizers.normalizeStringListRecord( raw.providerUsageAuthEnvVars, ), diff --git a/src/plugins/status-compatibility.ts b/src/plugins/status-compatibility.ts new file mode 100644 index 000000000000..eae1f235c347 --- /dev/null +++ b/src/plugins/status-compatibility.ts @@ -0,0 +1,28 @@ +/** Lightweight formatting contract for plugin compatibility notices. */ +import type { PluginCompatCode } from "./compat/registry.js"; + +export type PluginCompatibilityNotice = { + pluginId: string; + code: "hook-only" | "removed-session-transcript-file-api"; + compatCode: PluginCompatCode; + severity: "warn" | "info"; + message: string; +}; + +export type PluginCompatibilitySummary = { + noticeCount: number; + pluginCount: number; +}; + +export function formatPluginCompatibilityNotice(notice: PluginCompatibilityNotice): string { + return `${notice.pluginId} ${notice.message}`; +} + +export function summarizePluginCompatibility( + notices: PluginCompatibilityNotice[], +): PluginCompatibilitySummary { + return { + noticeCount: notices.length, + pluginCount: new Set(notices.map((notice) => notice.pluginId)).size, + }; +} diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 4d3996386f7d..e01dc7796ce8 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -10,7 +10,6 @@ import { inspectNativePluginMcpRuntimeSupport, } from "./bundle-mcp.js"; import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; -import type { PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig } from "./config-state.js"; import { appendPluginControlPlaneWorkspaceDiagnostic, @@ -38,6 +37,10 @@ import { resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js"; +import { + formatPluginCompatibilityNotice, + type PluginCompatibilityNotice, +} from "./status-compatibility.js"; import { buildPluginDependencyStatus, projectPluginDependencyHealth, @@ -57,18 +60,14 @@ export { } from "./status-snapshot.js"; export type { PluginCapabilityKind, PluginInspectShape } from "./inspect-shape.js"; -export type PluginCompatibilityNotice = { - pluginId: string; - code: "hook-only" | "removed-session-transcript-file-api"; - compatCode: PluginCompatCode; - severity: "warn" | "info"; - message: string; -}; - -export type PluginCompatibilitySummary = { - noticeCount: number; - pluginCount: number; -}; +export { + formatPluginCompatibilityNotice, + summarizePluginCompatibility, +} from "./status-compatibility.js"; +export type { + PluginCompatibilityNotice, + PluginCompatibilitySummary, +} from "./status-compatibility.js"; export type PluginInspectReport = { workspaceDir?: string; @@ -577,16 +576,3 @@ export function buildPluginCompatibilitySnapshotNotices(params?: { report: registrationReport, }); } - -export function formatPluginCompatibilityNotice(notice: PluginCompatibilityNotice): string { - return `${notice.pluginId} ${notice.message}`; -} - -export function summarizePluginCompatibility( - notices: PluginCompatibilityNotice[], -): PluginCompatibilitySummary { - return { - noticeCount: notices.length, - pluginCount: new Set(notices.map((notice) => notice.pluginId)).size, - }; -}