mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix: clarify plugin command alias diagnostics (#64242) (thanks @feiskyer)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
|
||||
| `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. |
|
||||
| `channelEnvVars` | No | `Record<string, string[]>` | 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"id": "memory-core",
|
||||
"kind": "memory",
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "dreaming",
|
||||
"kind": "runtime-slash",
|
||||
"cliCommand": "memory"
|
||||
}
|
||||
],
|
||||
"uiHints": {
|
||||
"dreaming.frequency": {
|
||||
"label": "Dreaming Frequency",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ` +
|
||||
|
||||
@@ -218,6 +218,7 @@ vi.mock("../plugins/manifest-registry.js", () => {
|
||||
params?.contract === "webSearchProviders"
|
||||
? mockWebSearchProviders.find((provider) => provider.id === params.value)?.pluginId
|
||||
: undefined,
|
||||
resolveManifestCommandAliasOwner: () => undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user