perf(cli): build root help from plugin metadata (#124562)

This commit is contained in:
Peter Steinberger
2026-08-16 05:43:35 -07:00
committed by GitHub
parent 7511601092
commit ebd23631ae
36 changed files with 480 additions and 88 deletions
+20
View File
@@ -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<string, string[]>` | Usage/billing-only provider credentials. OpenClaw uses these names for usage discovery and secret scrubbing but never for inference auth. |
| `providerAuthAliases` | No | `Record<string, string>` | 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 <feature>` 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.
+3 -1
View File
@@ -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
+7
View File
@@ -1,5 +1,12 @@
{
"id": "browser",
"cliCommands": [
{
"name": "browser",
"description": "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
"hasSubcommands": true
}
],
"enabledByDefault": true,
"activation": {
"onStartup": true,
+7
View File
@@ -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"],
@@ -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" }],
+7
View File
@@ -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": {
@@ -4,6 +4,13 @@
"stateMigrations": true
},
"name": "OpenClaw Memory",
"cliCommands": [
{
"name": "memory",
"description": "Search, inspect, and reindex memory files",
"hasSubcommands": true
}
],
"activation": {
"onStartup": false
},
@@ -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": {
@@ -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"]
+7
View File
@@ -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"]
@@ -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"],
+7
View File
@@ -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"]
+7
View File
@@ -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
},
+7
View File
@@ -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": [
@@ -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" }],
+7
View File
@@ -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"]
@@ -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": {
@@ -1,5 +1,12 @@
{
"id": "workboard",
"cliCommands": [
{
"name": "workboard",
"description": "Manage Workboard cards and worker dispatch",
"hasSubcommands": true
}
],
"doctorContract": {
"stateMigrations": true
},
@@ -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" }],
+1 -1
View File
@@ -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),
}));
+1 -1
View File
@@ -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 {
+23
View File
@@ -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");
});
});
+1 -1
View File
@@ -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";
+3 -2
View File
@@ -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();
+7 -1
View File
@@ -45,7 +45,13 @@ describe("activation planner", () => {
},
{
id: "browser",
commandAliases: [{ name: "browser" }],
cliCommands: [
{
name: "browser",
description: "Manage the browser",
hasSubcommands: true,
},
],
providers: [],
channels: [],
cliBackends: [],
+8
View File
@@ -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,
+96
View File
@@ -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<OpenClawPluginCliRootCommandDescriptor[]> {
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<string>();
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 [];
}
}
+101 -38
View File
@@ -39,6 +39,7 @@ vi.mock("../config/io.plugin-metadata.js", () => ({
}));
vi.mock("./plugin-metadata-snapshot.js", () => ({
isPluginMetadataSnapshotCompatible: () => true,
rebasePluginMetadataSnapshotManifestRegistry: <T>(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<typeof vi.fn>, 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({
+1 -17
View File
@@ -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<object, number>();
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<OpenClawPluginCliRootCommandDescriptor[]> {
return loadPluginCliDescriptors({ cfg, env, loaderOptions, logger: quietDescriptorLogger });
}
export async function registerPluginCliCommands(
program: Command,
cfg?: OpenClawConfig,
+2
View File
@@ -248,6 +248,7 @@ export type PluginManifestRecord = {
syntheticAuthRefs?: string[];
nonSecretAuthMarkers?: string[];
commandAliases?: PluginManifestCommandAlias[];
cliCommands?: PluginManifest["cliCommands"];
providerUsageAuthEnvVars?: Record<string, string[]>;
providerAuthAliases?: Record<string, string>;
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,
+32
View File
@@ -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<string>();
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<PluginManifestDefaultPlatform>([
"aix",
"android",
+9
View File
@@ -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<string, string[]>;
/** Provider ids that should reuse another provider id for auth lookup. */
@@ -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: [
{
+1
View File
@@ -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,
),
+28
View File
@@ -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,
};
}
+12 -26
View File
@@ -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,
};
}