Files
openclaw/src/cli/program/command-suggestions.ts
T
Glenn-Agent 2f12755498 fix: suggest close CLI commands (#91345)
Summary:
- The PR adds descriptor-backed CLI command suggestions for unknown root commands, wires them into Commander parse errors and early unowned-root diagnostics, and covers both paths with focused CLI tests.
- PR surface: Source +104, Tests +71. Total +175 across 5 files.
- Reproducibility: yes. for the behavior gap: current main's formatter and early unowned-root path emit generic diagnostics without closest-command hints, and the PR proof shows the after-fix CLI output.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix: suppress suggestions for plugin policy diagnostics
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'origin/main' into fix/83999-cli-command…
- PR branch already contained follow-up commit before automerge: test: align agent model expectations
- PR branch already contained follow-up commit before automerge: test: restore unrelated agent test fixture

Validation:
- ClawSweeper review passed for head b98f5b59e6.
- Required merge gates passed before the squash merge.

Prepared head SHA: b98f5b59e6
Review: https://github.com/openclaw/openclaw/pull/91345#issuecomment-4646215016

Co-authored-by: Glenn-Agent <glenn_agent@163.com>
2026-06-19 05:54:14 +00:00

91 lines
2.8 KiB
TypeScript

import { formatCliCommand } from "../command-format.js";
import { getCoreCliCommandNames } from "./core-command-descriptors.js";
import { getSubCliEntries } from "./subcli-descriptors.js";
const EXPLICIT_COMMAND_ALIASES = new Map<string, string>([
["upgrade", "update"],
["udpate", "update"],
]);
const MAX_SUGGESTIONS = 3;
function uniqueSortedCommandNames(commands: Iterable<string>): string[] {
return [...new Set([...commands].filter(Boolean))].toSorted((left, right) =>
left.localeCompare(right),
);
}
export function getKnownCliCommandNames(): string[] {
return uniqueSortedCommandNames([
...getCoreCliCommandNames(),
...getSubCliEntries().map((entry) => entry.name),
]);
}
export function levenshteinDistance(left: string, right: string): number {
if (left === right) {
return 0;
}
if (left.length === 0) {
return right.length;
}
if (right.length === 0) {
return left.length;
}
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
let current = Array.from<number>({ length: right.length + 1 });
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
current[0] = leftIndex + 1;
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
current[rightIndex + 1] = Math.min(
current[rightIndex] + 1,
previous[rightIndex + 1] + 1,
previous[rightIndex] + substitutionCost,
);
}
[previous, current] = [current, previous];
}
return previous[right.length] ?? 0;
}
export function suggestCliCommands(
input: string,
candidates: Iterable<string> = getKnownCliCommandNames(),
): string[] {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const knownCommands = uniqueSortedCommandNames(candidates);
const explicitAlias = EXPLICIT_COMMAND_ALIASES.get(normalizedInput);
if (explicitAlias && knownCommands.includes(explicitAlias)) {
return [explicitAlias];
}
const maxDistance = Math.max(1, Math.floor(normalizedInput.length * 0.4));
return knownCommands
.map((command) => ({ command, distance: levenshteinDistance(normalizedInput, command) }))
.filter(({ command, distance }) => command !== normalizedInput && distance <= maxDistance)
.toSorted(
(left, right) => left.distance - right.distance || left.command.localeCompare(right.command),
)
.slice(0, MAX_SUGGESTIONS)
.map(({ command }) => command);
}
export function formatCliCommandSuggestions(input: string): string | undefined {
const suggestions = suggestCliCommands(input);
if (suggestions.length === 0) {
return undefined;
}
const commandLines = suggestions
.map((command) => ` ${formatCliCommand(`openclaw ${command}`)}`)
.join("\n");
return `Did you mean this?\n${commandLines}`;
}