From beaff3c553d9942b554fc28d40c5f318df0012e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Apr 2026 14:30:43 +0100 Subject: [PATCH] fix: clarify plugin command alias diagnostics (#64242) (thanks @feiskyer) --- CHANGELOG.md | 1 + docs/plugins/manifest.md | 25 +++++++++ extensions/device-pair/openclaw.plugin.json | 6 +++ extensions/memory-core/openclaw.plugin.json | 7 +++ extensions/memory-core/src/cli.test.ts | 3 +- extensions/phone-control/openclaw.plugin.json | 6 +++ extensions/talk-voice/openclaw.plugin.json | 6 +++ src/cli/run-main.test.ts | 25 +++++++++ src/cli/run-main.ts | 53 ++++++++++++------- src/config/config.web-search-provider.test.ts | 1 + src/config/validation.ts | 25 +++------ src/plugins/manifest-registry.ts | 44 +++++++++++++++ src/plugins/manifest.ts | 50 +++++++++++++++++ 13 files changed, 214 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bde5693f651..498988f7cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai - Dreaming/startup: keep plugin-registered startup hooks alive across workspace hook reloads and include dreaming startup owners in the gateway startup plugin scope, so managed Dreaming cron registration comes back reliably after gateway boot. (#62327) Thanks @mbelinky. - Plugins: treat duplicate `registerService` calls from the same plugin id as idempotent so snapshot and activation loads no longer emit spurious `service already registered` diagnostics. (#62033, #64128) Thanks @ly85206559. - Discord/TTS: route auto voice replies through the native voice-note path so Discord receives Opus voice messages instead of regular audio attachments. (#64096) Thanks @LiuHuaize. +- Config/plugins: use plugin-owned command alias metadata when `plugins.allow` contains runtime command names like `dreaming`, and point users at the owning plugin instead of stale plugin-not-found guidance. (#64242) Thanks @feiskyer. ## 2026.4.9 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 05d47d61844..3a7ac52c8a0 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -147,6 +147,7 @@ Those belong in your plugin code and `package.json`. | `providers` | No | `string[]` | Provider ids owned by this plugin. | | `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | | `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | +| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. | | `providerAuthEnvVars` | No | `Record` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. | | `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. | | `channelEnvVars` | No | `Record` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. | @@ -183,6 +184,30 @@ OpenClaw reads this before provider runtime loads. | `cliDescription` | No | `string` | Description used in CLI help. | | `onboardingScopes` | No | `Array<"text-inference" \| "image-generation">` | Which onboarding surfaces this choice should appear in. If omitted, it defaults to `["text-inference"]`. | +## 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. + +```json +{ + "commandAliases": [ + { + "name": "dreaming", + "kind": "runtime-slash", + "cliCommand": "memory" + } + ] +} +``` + +| Field | Required | Type | What it means | +| ------------ | -------- | ----------------- | ----------------------------------------------------------------------- | +| `name` | Yes | `string` | Command name that belongs to this plugin. | +| `kind` | No | `"runtime-slash"` | Marks the alias as a chat slash command rather than a root CLI command. | +| `cliCommand` | No | `string` | Related root CLI command to suggest for CLI operations, if one exists. | + ## uiHints reference `uiHints` is a map from config field names to small rendering hints. diff --git a/extensions/device-pair/openclaw.plugin.json b/extensions/device-pair/openclaw.plugin.json index 1ab1d874da5..e8785dd683a 100644 --- a/extensions/device-pair/openclaw.plugin.json +++ b/extensions/device-pair/openclaw.plugin.json @@ -3,6 +3,12 @@ "enabledByDefault": true, "name": "Device Pairing", "description": "Generate setup codes and approve device pairing requests.", + "commandAliases": [ + { + "name": "pair", + "kind": "runtime-slash" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index 3b8b5a555bc..d0ab40f0364 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -1,6 +1,13 @@ { "id": "memory-core", "kind": "memory", + "commandAliases": [ + { + "name": "dreaming", + "kind": "runtime-slash", + "cliCommand": "memory" + } + ], "uiHints": { "dreaming.frequency": { "label": "Dreaming Frequency", diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 95c13020b40..ad5f1c00832 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -914,13 +914,14 @@ describe("memory cli", () => { it("previews rem harness output as json", async () => { await withTempWorkspace(async (workspaceDir) => { const nowMs = Date.now(); + const isoDay = new Date(nowMs).toISOString().slice(0, 10); await recordShortTermRecalls({ workspaceDir, query: "weather plans", nowMs, results: [ { - path: "memory/2026-04-03.md", + path: `memory/${isoDay}.md`, startLine: 2, endLine: 3, score: 0.92, diff --git a/extensions/phone-control/openclaw.plugin.json b/extensions/phone-control/openclaw.plugin.json index 3936aed06c2..367e6f29f14 100644 --- a/extensions/phone-control/openclaw.plugin.json +++ b/extensions/phone-control/openclaw.plugin.json @@ -3,6 +3,12 @@ "enabledByDefault": true, "name": "Phone Control", "description": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry.", + "commandAliases": [ + { + "name": "phone", + "kind": "runtime-slash" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/talk-voice/openclaw.plugin.json b/extensions/talk-voice/openclaw.plugin.json index 0979662dc4a..168fd2a74c6 100644 --- a/extensions/talk-voice/openclaw.plugin.json +++ b/extensions/talk-voice/openclaw.plugin.json @@ -3,6 +3,12 @@ "enabledByDefault": true, "name": "Talk Voice", "description": "Manage Talk voice selection (list/set).", + "commandAliases": [ + { + "name": "voice", + "kind": "runtime-slash" + } + ], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 6ee84f2509b..e95980faad7 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -123,4 +123,29 @@ describe("resolveMissingPluginCommandMessage", () => { expect(message).toContain("runtime slash command"); expect(message).not.toContain("plugins.allow"); }); + + it("points command names in plugins.allow at their parent plugin", () => { + const message = resolveMissingPluginCommandMessage("dreaming", { + plugins: { + allow: ["dreaming"], + }, + }); + expect(message).toContain('"dreaming" is not a plugin'); + expect(message).toContain('"memory-core"'); + expect(message).toContain("plugins.allow"); + }); + + it("explains parent plugin disablement for runtime command aliases", () => { + const message = resolveMissingPluginCommandMessage("dreaming", { + plugins: { + entries: { + "memory-core": { + enabled: false, + }, + }, + }, + }); + expect(message).toContain("plugins.entries.memory-core.enabled=false"); + expect(message).not.toContain("runtime slash command"); + }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 52b3968848b..330a0300614 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -11,6 +11,7 @@ import { isMainModule } from "../infra/is-main.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { enableConsoleCapture } from "../logging.js"; +import { resolveManifestCommandAliasOwner } from "../plugins/manifest-registry.js"; import { hasMemoryRuntime } from "../plugins/memory-state.js"; import { normalizeLowercaseStringOrEmpty, @@ -63,15 +64,6 @@ export function shouldUseRootHelpFastPath(argv: string[]): boolean { return resolveCliArgvInvocation(argv).isRootHelpInvocation; } -/** - * Maps well-known runtime command names to the plugin that provides them. - * Used to give actionable guidance when users try to run a runtime slash - * command (e.g. `/dreaming`) as a CLI command (`openclaw dreaming`). - */ -const RUNTIME_COMMAND_TO_PLUGIN_ID: Record = { - dreaming: "memory-core", -}; - export function resolveMissingPluginCommandMessage( pluginId: string, config?: OpenClawConfig, @@ -80,17 +72,6 @@ export function resolveMissingPluginCommandMessage( if (!normalizedPluginId) { return null; } - - // Check if this is a runtime slash command rather than a CLI command. - const parentPluginId = RUNTIME_COMMAND_TO_PLUGIN_ID[normalizedPluginId]; - if (parentPluginId) { - return ( - `"${normalizedPluginId}" is a runtime slash command (/${normalizedPluginId}), not a CLI command. ` + - `It is provided by the "${parentPluginId}" plugin. ` + - `Use \`openclaw memory\` for CLI memory operations, or \`/${normalizedPluginId}\` in a chat session.` - ); - } - const allow = Array.isArray(config?.plugins?.allow) && config.plugins.allow.length > 0 ? config.plugins.allow @@ -98,6 +79,38 @@ export function resolveMissingPluginCommandMessage( .map((entry) => normalizeOptionalLowercaseString(entry)) .filter(Boolean) : []; + const commandAlias = resolveManifestCommandAliasOwner({ + command: normalizedPluginId, + config, + }); + const parentPluginId = commandAlias?.pluginId; + if (parentPluginId) { + if (allow.length > 0 && !allow.includes(parentPluginId)) { + return ( + `"${normalizedPluginId}" is not a plugin; it is a command provided by the ` + + `"${parentPluginId}" plugin. Add "${parentPluginId}" to \`plugins.allow\` ` + + `instead of "${normalizedPluginId}".` + ); + } + if (config?.plugins?.entries?.[parentPluginId]?.enabled === false) { + return ( + `The \`openclaw ${normalizedPluginId}\` command is unavailable because ` + + `\`plugins.entries.${parentPluginId}.enabled=false\`. Re-enable that entry if you want ` + + "the bundled plugin command surface." + ); + } + if (commandAlias.kind === "runtime-slash") { + const cliHint = commandAlias.cliCommand + ? `Use \`openclaw ${commandAlias.cliCommand}\` for related CLI operations, or ` + : "Use "; + return ( + `"${normalizedPluginId}" is a runtime slash command (/${normalizedPluginId}), not a CLI command. ` + + `It is provided by the "${parentPluginId}" plugin. ` + + `${cliHint}\`/${normalizedPluginId}\` in a chat session.` + ); + } + } + if (allow.length > 0 && !allow.includes(normalizedPluginId)) { return ( `The \`openclaw ${normalizedPluginId}\` command is unavailable because ` + diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index d23c93a5215..1988fed4843 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -218,6 +218,7 @@ vi.mock("../plugins/manifest-registry.js", () => { params?.contract === "webSearchProviders" ? mockWebSearchProviders.find((provider) => provider.id === params.value)?.pluginId : undefined, + resolveManifestCommandAliasOwner: () => undefined, }; }); diff --git a/src/config/validation.ts b/src/config/validation.ts index df503d68f84..1e4821345ab 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -13,6 +13,7 @@ import { } from "../plugins/doctor-contract-registry.js"; import { loadPluginManifestRegistry, + resolveManifestCommandAliasOwner, resolveManifestContractPluginIds, } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; @@ -40,19 +41,6 @@ import { OpenClawSchema } from "./zod-schema.js"; const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]); -/** - * Maps well-known runtime command names to the plugin that provides them. - * Used to give actionable guidance when users accidentally put a command name - * (e.g. "dreaming") into `plugins.allow` instead of the parent plugin id. - */ -const COMMAND_NAME_TO_PLUGIN_ID: Record = { - dreaming: "memory-core", - // "active-memory" omitted: command name equals plugin id, no redirect needed. - voice: "talk-voice", - phone: "phone-control", - pair: "device-pair", -}; - type UnknownIssueRecord = Record; type ConfigPathSegment = string | number; type AllowedValuesCollection = { @@ -1053,13 +1041,16 @@ function validateConfigObjectWithPluginsBase( continue; } if (!knownIds.has(pluginId)) { - const parentPluginId = COMMAND_NAME_TO_PLUGIN_ID[pluginId]; - if (parentPluginId && parentPluginId !== pluginId && knownIds.has(parentPluginId)) { + const commandAlias = resolveManifestCommandAliasOwner({ + command: pluginId, + registry, + }); + if (commandAlias?.pluginId && knownIds.has(commandAlias.pluginId)) { warnings.push({ path: "plugins.allow", message: - `"${pluginId}" is not a plugin — it is a command provided by the "${parentPluginId}" plugin. ` + - `Use "${parentPluginId}" in plugins.allow instead.`, + `"${pluginId}" is not a plugin — it is a command provided by the "${commandAlias.pluginId}" plugin. ` + + `Use "${commandAlias.pluginId}" in plugins.allow instead.`, }); } else { pushMissingPluginIssue("plugins.allow", pluginId, { warnOnly: true }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index a2df35d9393..4c12abae9a4 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -17,6 +17,7 @@ import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadPluginManifest, type OpenClawPackageManifest, + type PluginManifestCommandAlias, type PluginManifestConfigContracts, type PluginManifest, type PluginManifestChannelConfig, @@ -78,6 +79,7 @@ export type PluginManifestRecord = { providerDiscoverySource?: string; modelSupport?: PluginManifestModelSupport; cliBackends: string[]; + commandAliases?: PluginManifestCommandAlias[]; providerAuthEnvVars?: Record; providerAuthAliases?: Record; channelEnvVars?: Record; @@ -204,6 +206,47 @@ export function resolveManifestContractOwnerPluginId(params: { )?.id; } +export type PluginManifestCommandAliasRecord = PluginManifestCommandAlias & { + pluginId: string; +}; + +export function resolveManifestCommandAliasOwner(params: { + command: string | undefined; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + registry?: PluginManifestRegistry; +}): PluginManifestCommandAliasRecord | undefined { + const normalizedCommand = normalizeOptionalLowercaseString(params.command); + if (!normalizedCommand) { + return undefined; + } + const registry = + params.registry ?? + loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + + const commandIsPluginId = registry.plugins.some( + (plugin) => normalizeOptionalLowercaseString(plugin.id) === normalizedCommand, + ); + if (commandIsPluginId) { + return undefined; + } + + for (const plugin of registry.plugins) { + const alias = plugin.commandAliases?.find( + (entry) => normalizeOptionalLowercaseString(entry.name) === normalizedCommand, + ); + if (alias) { + return { ...alias, pluginId: plugin.id }; + } + } + return undefined; +} + function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number { const raw = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); if (raw === "" || raw === "0") { @@ -315,6 +358,7 @@ function buildRecord(params: { : undefined, modelSupport: params.manifest.modelSupport, cliBackends: params.manifest.cliBackends ?? [], + commandAliases: params.manifest.commandAliases, providerAuthEnvVars: params.manifest.providerAuthEnvVars, providerAuthAliases: params.manifest.providerAuthAliases, channelEnvVars: params.manifest.channelEnvVars, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index beba5fd38d3..c82006d5933 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -34,6 +34,17 @@ export type PluginManifestModelSupport = { modelPatterns?: string[]; }; +export type PluginManifestCommandAliasKind = "runtime-slash"; + +export type PluginManifestCommandAlias = { + /** Command-like name users may put in plugin config by mistake. */ + name: string; + /** Command family, used for targeted diagnostics. */ + kind?: PluginManifestCommandAliasKind; + /** Optional root CLI command that handles related CLI operations. */ + cliCommand?: string; +}; + export type PluginManifestConfigLiteral = string | number | boolean | null; export type PluginManifestDangerousConfigFlag = { @@ -108,6 +119,11 @@ export type PluginManifest = { modelSupport?: PluginManifestModelSupport; /** Cheap startup activation lookup for plugin-owned CLI inference backends. */ cliBackends?: string[]; + /** + * Plugin-owned command aliases that should resolve to this plugin during + * config diagnostics before runtime loads. + */ + commandAliases?: PluginManifestCommandAlias[]; /** Cheap provider-auth env lookup without booting plugin runtime. */ providerAuthEnvVars?: Record; /** Provider ids that should reuse another provider id for auth lookup. */ @@ -357,6 +373,38 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo return Object.keys(modelSupport).length > 0 ? modelSupport : undefined; } +function normalizeManifestCommandAliases(value: unknown): PluginManifestCommandAlias[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const normalized: PluginManifestCommandAlias[] = []; + for (const entry of value) { + if (typeof entry === "string") { + const name = normalizeOptionalString(entry) ?? ""; + if (name) { + normalized.push({ name }); + } + continue; + } + if (!isRecord(entry)) { + continue; + } + const name = normalizeOptionalString(entry.name) ?? ""; + if (!name) { + continue; + } + const kind = entry.kind === "runtime-slash" ? entry.kind : undefined; + const cliCommand = normalizeOptionalString(entry.cliCommand) ?? ""; + normalized.push({ + name, + ...(kind ? { kind } : {}), + ...(cliCommand ? { cliCommand } : {}), + }); + } + return normalized.length > 0 ? normalized : undefined; +} + function normalizeProviderAuthChoices( value: unknown, ): PluginManifestProviderAuthChoice[] | undefined { @@ -539,6 +587,7 @@ export function loadPluginManifest( const providerDiscoveryEntry = normalizeOptionalString(raw.providerDiscoveryEntry); const modelSupport = normalizeManifestModelSupport(raw.modelSupport); const cliBackends = normalizeTrimmedStringList(raw.cliBackends); + const commandAliases = normalizeManifestCommandAliases(raw.commandAliases); const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars); const providerAuthAliases = normalizeStringRecord(raw.providerAuthAliases); const channelEnvVars = normalizeStringListRecord(raw.channelEnvVars); @@ -569,6 +618,7 @@ export function loadPluginManifest( providerDiscoveryEntry, modelSupport, cliBackends, + commandAliases, providerAuthEnvVars, providerAuthAliases, channelEnvVars,