mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
CLI: add nested subcommand fuzzy selection
This commit is contained in:
@@ -2,7 +2,10 @@ import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectCommandSelectorCandidates,
|
||||
collectDirectSubcommandSelectorCandidates,
|
||||
commandRequiresSubcommand,
|
||||
rankCommandSelectorCandidates,
|
||||
resolveCommandByPath,
|
||||
} from "./command-selector.js";
|
||||
|
||||
describe("command-selector", () => {
|
||||
@@ -48,4 +51,43 @@ describe("command-selector", () => {
|
||||
expect(ranked[0]?.label).toBe("message send");
|
||||
expect(ranked.some((candidate) => candidate.label === "status")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves commands by path", () => {
|
||||
const program = new Command();
|
||||
const models = program.command("models");
|
||||
const auth = models.command("auth").description("Auth");
|
||||
|
||||
expect(resolveCommandByPath(program, ["models"]))?.toBe(models);
|
||||
expect(resolveCommandByPath(program, ["models", "auth"]))?.toBe(auth);
|
||||
expect(resolveCommandByPath(program, ["models", "missing"])).toBeNull();
|
||||
});
|
||||
|
||||
it("detects commands that require subcommands", () => {
|
||||
const program = new Command();
|
||||
const models = program.command("models").description("Model commands");
|
||||
models.command("auth").description("Auth command");
|
||||
|
||||
const status = program
|
||||
.command("status")
|
||||
.description("Status")
|
||||
.action(() => undefined);
|
||||
|
||||
expect(commandRequiresSubcommand(models)).toBe(true);
|
||||
expect(commandRequiresSubcommand(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("collects direct subcommand candidates", () => {
|
||||
const program = new Command();
|
||||
const models = program.command("models").description("Model commands");
|
||||
models.command("auth").description("Authenticate");
|
||||
models.command("scan").description("Scan models");
|
||||
|
||||
const candidates = collectDirectSubcommandSelectorCandidates(program, ["models"]);
|
||||
|
||||
expect(candidates.map((candidate) => candidate.label)).toEqual(["auth", "scan"]);
|
||||
expect(candidates.map((candidate) => candidate.path.join(" "))).toEqual([
|
||||
"models auth",
|
||||
"models scan",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getProgramContext } from "./program-context.js";
|
||||
import { getSubCliEntries, registerSubCliByName } from "./register.subclis.js";
|
||||
|
||||
const SHOW_HELP_VALUE = "__show_help__";
|
||||
const BACK_TO_MAIN_VALUE = "__back_to_main__";
|
||||
const PATH_SEPARATOR = "\u0000";
|
||||
const MAX_RESULTS = 200;
|
||||
|
||||
@@ -21,11 +22,23 @@ type PreparedCommandSelectorCandidate = CommandSelectorCandidate & {
|
||||
searchTextLower: string;
|
||||
};
|
||||
|
||||
type SelectorPromptResult = string[] | "back_to_main" | null;
|
||||
|
||||
function isHiddenCommand(command: Command): boolean {
|
||||
// Commander stores hidden state on a private field.
|
||||
return Boolean((command as Command & { _hidden?: boolean })._hidden);
|
||||
}
|
||||
|
||||
function shouldSkipCommand(command: Command, parentDepth: number): boolean {
|
||||
if (isHiddenCommand(command) || command.name() === "help") {
|
||||
return true;
|
||||
}
|
||||
if (parentDepth === 0 && command.name() === "interactive") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveCommandDescription(command: Command): string {
|
||||
const summary = typeof command.summary === "function" ? command.summary().trim() : "";
|
||||
if (summary) {
|
||||
@@ -38,6 +51,14 @@ function resolveCommandDescription(command: Command): string {
|
||||
return "Run this command";
|
||||
}
|
||||
|
||||
function prepareSortedCandidates(
|
||||
raw: CommandSelectorCandidate[],
|
||||
): PreparedCommandSelectorCandidate[] {
|
||||
const prepared = prepareSearchItems(raw);
|
||||
prepared.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function collectCandidatesRecursive(params: {
|
||||
command: Command;
|
||||
parentPath: string[];
|
||||
@@ -45,10 +66,7 @@ function collectCandidatesRecursive(params: {
|
||||
out: CommandSelectorCandidate[];
|
||||
}): void {
|
||||
for (const child of params.command.commands) {
|
||||
if (isHiddenCommand(child) || child.name() === "help") {
|
||||
continue;
|
||||
}
|
||||
if (params.parentPath.length === 0 && child.name() === "interactive") {
|
||||
if (shouldSkipCommand(child, params.parentPath.length)) {
|
||||
continue;
|
||||
}
|
||||
const path = [...params.parentPath, child.name()];
|
||||
@@ -78,9 +96,56 @@ export function collectCommandSelectorCandidates(
|
||||
const seen = new Set<string>();
|
||||
const raw: CommandSelectorCandidate[] = [];
|
||||
collectCandidatesRecursive({ command: program, parentPath: [], seen, out: raw });
|
||||
const prepared = prepareSearchItems(raw);
|
||||
prepared.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return prepared;
|
||||
return prepareSortedCandidates(raw);
|
||||
}
|
||||
|
||||
export function resolveCommandByPath(program: Command, path: string[]): Command | null {
|
||||
let current: Command = program;
|
||||
for (const segment of path) {
|
||||
const next = current.commands.find((child) => child.name() === segment);
|
||||
if (!next) {
|
||||
return null;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasActionHandler(command: Command): boolean {
|
||||
return Boolean((command as Command & { _actionHandler?: unknown })._actionHandler);
|
||||
}
|
||||
|
||||
export function commandRequiresSubcommand(command: Command): boolean {
|
||||
const visibleChildren = command.commands.filter((child) => !shouldSkipCommand(child, 1));
|
||||
if (visibleChildren.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !hasActionHandler(command);
|
||||
}
|
||||
|
||||
export function collectDirectSubcommandSelectorCandidates(
|
||||
program: Command,
|
||||
basePath: string[],
|
||||
): PreparedCommandSelectorCandidate[] {
|
||||
const parent = resolveCommandByPath(program, basePath);
|
||||
if (!parent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw: CommandSelectorCandidate[] = [];
|
||||
for (const child of parent.commands) {
|
||||
if (shouldSkipCommand(child, basePath.length)) {
|
||||
continue;
|
||||
}
|
||||
const path = [...basePath, child.name()];
|
||||
raw.push({
|
||||
path,
|
||||
label: child.name(),
|
||||
description: resolveCommandDescription(child),
|
||||
searchText: `${child.name()} ${path.join(" ")}`,
|
||||
});
|
||||
}
|
||||
return prepareSortedCandidates(raw);
|
||||
}
|
||||
|
||||
export function rankCommandSelectorCandidates(
|
||||
@@ -115,30 +180,48 @@ async function hydrateProgramCommandsForSelector(program: Command): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInteractiveCommandSelector(program: Command): Promise<string[] | null> {
|
||||
await hydrateProgramCommandsForSelector(program);
|
||||
function serializePath(path: string[]): string {
|
||||
return path.join(PATH_SEPARATOR);
|
||||
}
|
||||
|
||||
const candidates = collectCommandSelectorCandidates(program);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
function deserializePath(value: string): string[] {
|
||||
return value
|
||||
.split(PATH_SEPARATOR)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptForCommandSelection(params: {
|
||||
message: string;
|
||||
placeholder: string;
|
||||
candidates: PreparedCommandSelectorCandidate[];
|
||||
includeBackToMain?: boolean;
|
||||
}): Promise<SelectorPromptResult> {
|
||||
const selection = await clackAutocomplete<string>({
|
||||
message: stylePromptMessage("Find and run a command") ?? "Find and run a command",
|
||||
placeholder: "Type to fuzzy-search (e.g. msg snd)",
|
||||
message: params.message,
|
||||
placeholder: params.placeholder,
|
||||
maxItems: 10,
|
||||
// We pre-rank the list with our fuzzy scorer, then opt out of clack's own
|
||||
// filter so item order stays stable and score-based.
|
||||
filter: () => true,
|
||||
options() {
|
||||
const query = this.userInput.trim();
|
||||
const ranked = rankCommandSelectorCandidates(candidates, query).slice(0, MAX_RESULTS);
|
||||
const ranked = rankCommandSelectorCandidates(params.candidates, query).slice(0, MAX_RESULTS);
|
||||
return [
|
||||
...ranked.map((candidate) => ({
|
||||
value: candidate.path.join(PATH_SEPARATOR),
|
||||
value: serializePath(candidate.path),
|
||||
label: candidate.label,
|
||||
hint: stylePromptHint(candidate.description),
|
||||
})),
|
||||
...(params.includeBackToMain
|
||||
? [
|
||||
{
|
||||
value: BACK_TO_MAIN_VALUE,
|
||||
label: "../",
|
||||
hint: stylePromptHint("Back to main command selector"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: SHOW_HELP_VALUE,
|
||||
label: "Show help",
|
||||
@@ -151,9 +234,62 @@ export async function runInteractiveCommandSelector(program: Command): Promise<s
|
||||
if (isCancel(selection) || selection === SHOW_HELP_VALUE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selection
|
||||
.split(PATH_SEPARATOR)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (selection === BACK_TO_MAIN_VALUE) {
|
||||
return "back_to_main";
|
||||
}
|
||||
return deserializePath(selection);
|
||||
}
|
||||
|
||||
export async function runInteractiveCommandSelector(program: Command): Promise<string[] | null> {
|
||||
await hydrateProgramCommandsForSelector(program);
|
||||
|
||||
const mainCandidates = collectCommandSelectorCandidates(program);
|
||||
if (mainCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const mainSelection = await promptForCommandSelection({
|
||||
message: stylePromptMessage("Find and run a command") ?? "Find and run a command",
|
||||
placeholder: "Type to fuzzy-search (e.g. msg snd)",
|
||||
candidates: mainCandidates,
|
||||
});
|
||||
if (!mainSelection || mainSelection === "back_to_main") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let selectedPath = mainSelection;
|
||||
let selectedCommand = resolveCommandByPath(program, selectedPath);
|
||||
if (!selectedCommand || !commandRequiresSubcommand(selectedCommand)) {
|
||||
return selectedPath;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const subcommandCandidates = collectDirectSubcommandSelectorCandidates(program, selectedPath);
|
||||
if (subcommandCandidates.length === 0) {
|
||||
return selectedPath;
|
||||
}
|
||||
|
||||
const subSelection = await promptForCommandSelection({
|
||||
message:
|
||||
stylePromptMessage(`Select subcommand for ${selectedPath.join(" ")}`) ??
|
||||
`Select subcommand for ${selectedPath.join(" ")}`,
|
||||
placeholder: "Type to fuzzy-search subcommands",
|
||||
candidates: subcommandCandidates,
|
||||
includeBackToMain: true,
|
||||
});
|
||||
if (!subSelection) {
|
||||
return null;
|
||||
}
|
||||
if (subSelection === "back_to_main") {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedPath = subSelection;
|
||||
selectedCommand = resolveCommandByPath(program, selectedPath);
|
||||
if (!selectedCommand || !commandRequiresSubcommand(selectedCommand)) {
|
||||
return selectedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user