Skip to content

Commit e48296c

Browse files
authored
Merge b98f5b5 into 324ad54
2 parents 324ad54 + b98f5b5 commit e48296c

5 files changed

Lines changed: 179 additions & 4 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { formatCliCommand } from "../command-format.js";
2+
import { getCoreCliCommandNames } from "./core-command-descriptors.js";
3+
import { getSubCliEntries } from "./subcli-descriptors.js";
4+
5+
const EXPLICIT_COMMAND_ALIASES = new Map<string, string>([
6+
["upgrade", "update"],
7+
["udpate", "update"],
8+
]);
9+
10+
const MAX_SUGGESTIONS = 3;
11+
12+
function uniqueSortedCommandNames(commands: Iterable<string>): string[] {
13+
return [...new Set([...commands].filter(Boolean))].toSorted((left, right) =>
14+
left.localeCompare(right),
15+
);
16+
}
17+
18+
export function getKnownCliCommandNames(): string[] {
19+
return uniqueSortedCommandNames([
20+
...getCoreCliCommandNames(),
21+
...getSubCliEntries().map((entry) => entry.name),
22+
]);
23+
}
24+
25+
export function levenshteinDistance(left: string, right: string): number {
26+
if (left === right) {
27+
return 0;
28+
}
29+
if (left.length === 0) {
30+
return right.length;
31+
}
32+
if (right.length === 0) {
33+
return left.length;
34+
}
35+
36+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
37+
let current = Array.from<number>({ length: right.length + 1 });
38+
39+
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
40+
current[0] = leftIndex + 1;
41+
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
42+
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
43+
current[rightIndex + 1] = Math.min(
44+
current[rightIndex] + 1,
45+
previous[rightIndex + 1] + 1,
46+
previous[rightIndex] + substitutionCost,
47+
);
48+
}
49+
[previous, current] = [current, previous];
50+
}
51+
52+
return previous[right.length] ?? 0;
53+
}
54+
55+
export function suggestCliCommands(
56+
input: string,
57+
candidates: Iterable<string> = getKnownCliCommandNames(),
58+
): string[] {
59+
const normalizedInput = input.trim().toLowerCase();
60+
if (!normalizedInput) {
61+
return [];
62+
}
63+
64+
const knownCommands = uniqueSortedCommandNames(candidates);
65+
const explicitAlias = EXPLICIT_COMMAND_ALIASES.get(normalizedInput);
66+
if (explicitAlias && knownCommands.includes(explicitAlias)) {
67+
return [explicitAlias];
68+
}
69+
70+
const maxDistance = Math.max(1, Math.floor(normalizedInput.length * 0.4));
71+
return knownCommands
72+
.map((command) => ({ command, distance: levenshteinDistance(normalizedInput, command) }))
73+
.filter(({ command, distance }) => command !== normalizedInput && distance <= maxDistance)
74+
.toSorted(
75+
(left, right) => left.distance - right.distance || left.command.localeCompare(right.command),
76+
)
77+
.slice(0, MAX_SUGGESTIONS)
78+
.map(({ command }) => command);
79+
}
80+
81+
export function formatCliCommandSuggestions(input: string): string | undefined {
82+
const suggestions = suggestCliCommands(input);
83+
if (suggestions.length === 0) {
84+
return undefined;
85+
}
86+
const commandLines = suggestions
87+
.map((command) => ` ${formatCliCommand(`openclaw ${command}`)}`)
88+
.join("\n");
89+
return `Did you mean this?\n${commandLines}`;
90+
}

src/cli/program/error-output.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,42 @@ describe("formatCliParseErrorOutput", () => {
1313
);
1414
});
1515

16+
it("suggests close known commands for unknown commands", () => {
17+
const output = formatCliParseErrorOutput("error: unknown command 'upate'\n", {
18+
argv: ["node", "openclaw", "upate"],
19+
});
20+
21+
expect(output).toBe(
22+
'OpenClaw does not know the command "upate".\nDid you mean this?\n openclaw update\nTry: openclaw --help\nPlugin command? openclaw plugins list\nDocs: https://docs.openclaw.ai/cli\n',
23+
);
24+
});
25+
26+
it("suggests explicit aliases for common adjacent terminology", () => {
27+
const output = formatCliParseErrorOutput("error: unknown command 'upgrade'\n", {
28+
argv: ["node", "openclaw", "upgrade"],
29+
});
30+
31+
expect(output).toContain("Did you mean this?\n openclaw update\n");
32+
});
33+
34+
it("preserves active profile context in command suggestions", () => {
35+
const originalProfile = process.env.OPENCLAW_PROFILE;
36+
process.env.OPENCLAW_PROFILE = "work";
37+
try {
38+
const output = formatCliParseErrorOutput("error: unknown command 'doctr'\n", {
39+
argv: ["node", "openclaw", "doctr"],
40+
});
41+
42+
expect(output).toContain("Did you mean this?\n openclaw --profile work doctor\n");
43+
} finally {
44+
if (originalProfile === undefined) {
45+
delete process.env.OPENCLAW_PROFILE;
46+
} else {
47+
process.env.OPENCLAW_PROFILE = originalProfile;
48+
}
49+
}
50+
});
51+
1652
it("points unknown options at the active command help", () => {
1753
const output = formatCliParseErrorOutput("error: unknown option '--wat'\n", {
1854
argv: ["node", "openclaw", "channels", "status", "--wat"],

src/cli/program/error-output.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
33
import { theme } from "../../../packages/terminal-core/src/theme.js";
44
import { getCommandPathWithRootOptions } from "../argv.js";
55
import { formatCliCommand } from "../command-format.js";
6+
import { formatCliCommandSuggestions } from "./command-suggestions.js";
67

78
type FormatCliParseErrorOptions = {
89
argv?: string[];
@@ -53,6 +54,7 @@ export function formatCliParseErrorOutput(
5354
const command = unknownCommand[1] ?? "";
5455
return lines(
5556
theme.error(`OpenClaw does not know the command ${quote(command)}.`),
57+
formatCliCommandSuggestions(command),
5658
formatHelpHint(options.argv, { root: true }),
5759
`${theme.muted("Plugin command?")} ${theme.command(formatCliCommand("openclaw plugins list"))}`,
5860
formatDocsHint(),

src/cli/run-main.exit.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,6 +2148,41 @@ describe("runCli exit behavior", () => {
21482148
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
21492149
});
21502150

2151+
it("suggests close known commands for unowned command roots before proxy startup", async () => {
2152+
await expect(runCli(["node", "openclaw", "upate"])).rejects.toThrow(
2153+
"Did you mean this?\n openclaw update",
2154+
);
2155+
2156+
expect(startProxyMock).not.toHaveBeenCalled();
2157+
expect(tryRouteCliMock).not.toHaveBeenCalled();
2158+
expect(buildProgramMock).not.toHaveBeenCalled();
2159+
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
2160+
});
2161+
2162+
it("keeps suggestions out of plugin-policy diagnostics", async () => {
2163+
resolveManifestCommandAliasOwnerMock.mockReturnValueOnce({
2164+
pluginId: "codex",
2165+
kind: "runtime-slash",
2166+
cliCommand: "plugins",
2167+
});
2168+
2169+
let error: unknown;
2170+
try {
2171+
await runCli(["node", "openclaw", "codex"]);
2172+
} catch (caught) {
2173+
error = caught;
2174+
}
2175+
2176+
expect(error).toBeInstanceOf(Error);
2177+
expect((error as Error).message).toContain("runtime slash command");
2178+
expect((error as Error).message).toContain("/codex");
2179+
expect((error as Error).message).not.toContain("Did you mean this?");
2180+
expect(startProxyMock).not.toHaveBeenCalled();
2181+
expect(tryRouteCliMock).not.toHaveBeenCalled();
2182+
expect(buildProgramMock).not.toHaveBeenCalled();
2183+
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
2184+
});
2185+
21512186
it("rejects unowned command roots even when --help is appended (regression for #81077)", async () => {
21522187
await expect(runCli(["node", "openclaw", "foo", "--help"])).rejects.toThrow(
21532188
'No built-in command or plugin CLI metadata owns "foo"',

src/cli/run-main.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
} from "./gateway-run-argv.js";
3434
import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js";
3535
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
36+
import { formatCliCommandSuggestions } from "./program/command-suggestions.js";
3637
import { getCoreCliCommandNames } from "./program/core-command-descriptors.js";
3738
import { getSubCliEntries } from "./program/subcli-descriptors.js";
3839
import {
@@ -600,14 +601,25 @@ async function resolveUnownedCliPrimaryMessage(params: {
600601
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
601602
await loadManifestCommandAliasesRuntimeModule();
602603
const cliCommandSurfaceOwner = await resolveCliCommandSurfaceOwner(params);
603-
return (
604-
resolveMissingPluginCommandMessageFromPolicy(params.primary, params.config, {
604+
const pluginPolicyMessage = resolveMissingPluginCommandMessageFromPolicy(
605+
params.primary,
606+
params.config,
607+
{
605608
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
606609
resolveToolOwner: resolveManifestToolOwner,
607610
resolveCliCommandSurfaceOwner: () => cliCommandSurfaceOwner,
608-
}) ??
609-
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`
611+
},
610612
);
613+
if (pluginPolicyMessage) {
614+
return pluginPolicyMessage;
615+
}
616+
const suggestion = formatCliCommandSuggestions(params.primary);
617+
return [
618+
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`,
619+
suggestion,
620+
]
621+
.filter(Boolean)
622+
.join("\n");
611623
}
612624

613625
async function bootstrapCliProxyCaptureAndDispatcher(

0 commit comments

Comments
 (0)