CLI: add run-current and nested picker for mixed commands

This commit is contained in:
Benjamin Jesuiter
2026-02-16 21:47:20 +01:00
parent bde982ae7c
commit 92e1e87034
2 changed files with 31 additions and 11 deletions

View File

@@ -65,7 +65,11 @@ describe("command-selector", () => {
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 auth = models
.command("auth")
.description("Auth command")
.action(() => undefined);
auth.command("login").description("Login auth profile");
const status = program
.command("status")
@@ -73,6 +77,7 @@ describe("command-selector", () => {
.action(() => undefined);
expect(commandRequiresSubcommand(models)).toBe(true);
expect(commandRequiresSubcommand(auth)).toBe(true);
expect(commandRequiresSubcommand(status)).toBe(false);
});

View File

@@ -8,6 +8,7 @@ import { getSubCliEntries, registerSubCliByName } from "./register.subclis.js";
const SHOW_HELP_VALUE = "__show_help__";
const BACK_TO_MAIN_VALUE = "__back_to_main__";
const RUN_CURRENT_VALUE = "__run_current__";
const PATH_SEPARATOR = "\u0000";
const MAX_RESULTS = 200;
@@ -22,7 +23,7 @@ type PreparedCommandSelectorCandidate = CommandSelectorCandidate & {
searchTextLower: string;
};
type SelectorPromptResult = string[] | "back_to_main" | null;
type SelectorPromptResult = string[] | "back_to_main" | "run_current" | null;
function isHiddenCommand(command: Command): boolean {
// Commander stores hidden state on a private field.
@@ -111,16 +112,9 @@ export function resolveCommandByPath(program: Command, path: string[]): Command
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);
return visibleChildren.length > 0;
}
export function collectDirectSubcommandSelectorCandidates(
@@ -196,6 +190,8 @@ async function promptForCommandSelection(params: {
placeholder: string;
candidates: PreparedCommandSelectorCandidate[];
includeBackToMain?: boolean;
includeRunCurrent?: boolean;
currentPath?: string[];
}): Promise<SelectorPromptResult> {
const selection = await clackAutocomplete<string>({
message: params.message,
@@ -213,6 +209,17 @@ async function promptForCommandSelection(params: {
label: candidate.label,
hint: stylePromptHint(candidate.description),
})),
...(params.includeRunCurrent
? [
{
value: RUN_CURRENT_VALUE,
label: "./",
hint: stylePromptHint(
`Run ${params.currentPath?.join(" ") ?? "selected command"} directly`,
),
},
]
: []),
...(params.includeBackToMain
? [
{
@@ -237,6 +244,9 @@ async function promptForCommandSelection(params: {
if (selection === BACK_TO_MAIN_VALUE) {
return "back_to_main";
}
if (selection === RUN_CURRENT_VALUE) {
return "run_current";
}
return deserializePath(selection);
}
@@ -254,7 +264,7 @@ export async function runInteractiveCommandSelector(program: Command): Promise<s
placeholder: "Type to fuzzy-search (e.g. msg snd)",
candidates: mainCandidates,
});
if (!mainSelection || mainSelection === "back_to_main") {
if (!mainSelection || mainSelection === "back_to_main" || mainSelection === "run_current") {
return null;
}
@@ -276,6 +286,8 @@ export async function runInteractiveCommandSelector(program: Command): Promise<s
`Select subcommand for ${selectedPath.join(" ")}`,
placeholder: "Type to fuzzy-search subcommands",
candidates: subcommandCandidates,
includeRunCurrent: true,
currentPath: selectedPath,
includeBackToMain: true,
});
if (!subSelection) {
@@ -284,6 +296,9 @@ export async function runInteractiveCommandSelector(program: Command): Promise<s
if (subSelection === "back_to_main") {
break;
}
if (subSelection === "run_current") {
return selectedPath;
}
selectedPath = subSelection;
selectedCommand = resolveCommandByPath(program, selectedPath);