mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
perf(cli): build root help from plugin metadata (#124562)
This commit is contained in:
committed by
GitHub
parent
7511601092
commit
ebd23631ae
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"id": "browser",
|
||||
"cliCommands": [
|
||||
{
|
||||
"name": "browser",
|
||||
"description": "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
|
||||
"hasSubcommands": true
|
||||
}
|
||||
],
|
||||
"enabledByDefault": true,
|
||||
"activation": {
|
||||
"onStartup": true,
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ describe("activation planner", () => {
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
commandAliases: [{ name: "browser" }],
|
||||
cliCommands: [
|
||||
{
|
||||
name: "browser",
|
||||
description: "Manage the browser",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
providers: [],
|
||||
channels: [],
|
||||
cliBackends: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user