CLI: add nested subcommand fuzzy selection

This commit is contained in:
Benjamin Jesuiter
2026-02-16 21:33:04 +01:00
parent eb87af9ea5
commit bde982ae7c
2 changed files with 200 additions and 22 deletions

View File

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

View File

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