diff --git a/src/cli/program/command-selector.test.ts b/src/cli/program/command-selector.test.ts index ffd633e327..612b139550 100644 --- a/src/cli/program/command-selector.test.ts +++ b/src/cli/program/command-selector.test.ts @@ -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", + ]); + }); }); diff --git a/src/cli/program/command-selector.ts b/src/cli/program/command-selector.ts index 297050333b..fa0f50fa97 100644 --- a/src/cli/program/command-selector.ts +++ b/src/cli/program/command-selector.ts @@ -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(); 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 { - 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 { const selection = await clackAutocomplete({ - 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 segment.trim()) - .filter(Boolean); + if (selection === BACK_TO_MAIN_VALUE) { + return "back_to_main"; + } + return deserializePath(selection); +} + +export async function runInteractiveCommandSelector(program: Command): Promise { + 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; + } + } + } }