fix: clarify plugin command alias diagnostics (#64242) (thanks @feiskyer)

This commit is contained in:
Peter Steinberger
2026-04-10 14:30:43 +01:00
parent 8cb45c051e
commit beaff3c553
13 changed files with 214 additions and 38 deletions

View File

@@ -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");
});
});

View File

@@ -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<string, string> = {
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 ` +

View File

@@ -218,6 +218,7 @@ vi.mock("../plugins/manifest-registry.js", () => {
params?.contract === "webSearchProviders"
? mockWebSearchProviders.find((provider) => provider.id === params.value)?.pluginId
: undefined,
resolveManifestCommandAliasOwner: () => undefined,
};
});

View File

@@ -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<string, string> = {
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<string, unknown>;
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 });

View File

@@ -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<string, string[]>;
providerAuthAliases?: Record<string, string>;
channelEnvVars?: Record<string, string[]>;
@@ -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,

View File

@@ -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<string, string[]>;
/** 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,