From 3e9e9258a482a4fb958c46d7b6831ea8e442ab4e Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Mon, 16 Feb 2026 20:18:48 +0100 Subject: [PATCH] CLI: add fuzzy selector when no command is given --- src/cli/program/command-selector.test.ts | 51 +++++++ src/cli/program/command-selector.ts | 185 +++++++++++++++++++++++ src/cli/run-main.test.ts | 59 ++++++++ src/cli/run-main.ts | 42 ++++- 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 src/cli/program/command-selector.test.ts create mode 100644 src/cli/program/command-selector.ts diff --git a/src/cli/program/command-selector.test.ts b/src/cli/program/command-selector.test.ts new file mode 100644 index 0000000000..ffd633e327 --- /dev/null +++ b/src/cli/program/command-selector.test.ts @@ -0,0 +1,51 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { + collectCommandSelectorCandidates, + rankCommandSelectorCandidates, +} from "./command-selector.js"; + +describe("command-selector", () => { + it("collects nested command paths", () => { + const program = new Command(); + const message = program.command("message").description("Manage messages"); + message.command("send").description("Send a message"); + message.command("read").description("Read messages"); + program.command("status").description("Show status"); + + const candidates = collectCommandSelectorCandidates(program); + const labels = candidates.map((candidate) => candidate.label); + + expect(labels).toContain("message"); + expect(labels).toContain("message send"); + expect(labels).toContain("message read"); + expect(labels).toContain("status"); + }); + + it("skips hidden commands", () => { + const program = new Command(); + program.command("visible").description("Visible command"); + const secret = program.command("secret").description("Secret command"); + (secret as Command & { _hidden?: boolean })._hidden = true; + + const candidates = collectCommandSelectorCandidates(program); + const labels = candidates.map((candidate) => candidate.label); + + expect(labels).toContain("visible"); + expect(labels).not.toContain("secret"); + }); + + it("supports fuzzy ranking", () => { + const program = new Command(); + const message = program.command("message").description("Manage messages"); + message.command("send").description("Send a message"); + message.command("search").description("Search messages"); + program.command("status").description("Show status"); + + const candidates = collectCommandSelectorCandidates(program); + const ranked = rankCommandSelectorCandidates(candidates, "msg snd"); + + expect(ranked[0]?.label).toBe("message send"); + expect(ranked.some((candidate) => candidate.label === "status")).toBe(false); + }); +}); diff --git a/src/cli/program/command-selector.ts b/src/cli/program/command-selector.ts new file mode 100644 index 0000000000..e86bf24825 --- /dev/null +++ b/src/cli/program/command-selector.ts @@ -0,0 +1,185 @@ +import type { Command } from "commander"; +import { isCancel, select as clackSelect, text as clackText } from "@clack/prompts"; +import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; +import { theme } from "../../terminal/theme.js"; +import { fuzzyFilterLower, prepareSearchItems } from "../../tui/components/fuzzy-filter.js"; +import { getCoreCliCommandNames, registerCoreCliByName } from "./command-registry.js"; +import { getProgramContext } from "./program-context.js"; +import { getSubCliEntries, registerSubCliByName } from "./register.subclis.js"; + +const SEARCH_AGAIN_VALUE = "__search_again__"; +const SHOW_HELP_VALUE = "__show_help__"; +const PATH_SEPARATOR = "\u0000"; +const MAX_MATCHES = 24; + +type CommandSelectorCandidate = { + path: string[]; + label: string; + description: string; + searchText: string; +}; + +type PreparedCommandSelectorCandidate = CommandSelectorCandidate & { + searchTextLower: string; +}; + +function isHiddenCommand(command: Command): boolean { + // Commander stores hidden state on a private field. + return Boolean((command as Command & { _hidden?: boolean })._hidden); +} + +function resolveCommandDescription(command: Command): string { + const summary = typeof command.summary === "function" ? command.summary().trim() : ""; + if (summary) { + return summary; + } + const description = command.description().trim(); + if (description) { + return description; + } + return "Run this command"; +} + +function collectCandidatesRecursive(params: { + command: Command; + parentPath: string[]; + seen: Set; + out: CommandSelectorCandidate[]; +}): void { + for (const child of params.command.commands) { + if (isHiddenCommand(child) || child.name() === "help") { + continue; + } + const path = [...params.parentPath, child.name()]; + const label = path.join(" "); + if (!params.seen.has(label)) { + params.seen.add(label); + params.out.push({ + path, + label, + description: resolveCommandDescription(child), + searchText: path.join(" "), + }); + } + + collectCandidatesRecursive({ + command: child, + parentPath: path, + seen: params.seen, + out: params.out, + }); + } +} + +export function collectCommandSelectorCandidates( + program: Command, +): PreparedCommandSelectorCandidate[] { + 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; +} + +export function rankCommandSelectorCandidates( + candidates: PreparedCommandSelectorCandidate[], + query: string, +): PreparedCommandSelectorCandidate[] { + const queryLower = query.trim().toLowerCase(); + if (!queryLower) { + return candidates; + } + return fuzzyFilterLower(candidates, queryLower); +} + +async function hydrateProgramCommandsForSelector(program: Command): Promise { + const ctx = getProgramContext(program); + if (ctx) { + for (const name of getCoreCliCommandNames()) { + try { + await registerCoreCliByName(program, ctx, name); + } catch { + // Keep selector usable even if one registrar fails in this environment. + } + } + } + + for (const entry of getSubCliEntries()) { + try { + await registerSubCliByName(program, entry.name); + } catch { + // Keep selector usable even if one registrar fails in this environment. + } + } +} + +export async function runInteractiveCommandSelector(program: Command): Promise { + await hydrateProgramCommandsForSelector(program); + + const candidates = collectCommandSelectorCandidates(program); + if (candidates.length === 0) { + return null; + } + + let lastQuery = ""; + + while (true) { + const queryResult = await clackText({ + message: stylePromptMessage("Find a command (fuzzy)") ?? "Find a command (fuzzy)", + placeholder: "message send", + defaultValue: lastQuery, + }); + if (isCancel(queryResult)) { + return null; + } + + const query = String(queryResult ?? "").trim(); + lastQuery = query; + + const matches = rankCommandSelectorCandidates(candidates, query); + if (matches.length === 0) { + console.error(theme.warn("No matching commands. Try a different search.")); + continue; + } + + const shown = matches.slice(0, MAX_MATCHES); + const selection = await clackSelect({ + message: + stylePromptMessage( + shown.length === matches.length + ? `Select a command (${matches.length} matches)` + : `Select a command (showing ${shown.length} of ${matches.length} matches)`, + ) ?? "Select a command", + options: [ + ...shown.map((candidate) => ({ + value: candidate.path.join(PATH_SEPARATOR), + label: candidate.label, + hint: stylePromptHint(candidate.description), + })), + { + value: SEARCH_AGAIN_VALUE, + label: "Search again", + hint: stylePromptHint("Change your fuzzy query"), + }, + { + value: SHOW_HELP_VALUE, + label: "Show help", + hint: stylePromptHint("Skip selector and print CLI help"), + }, + ], + }); + + if (isCancel(selection) || selection === SHOW_HELP_VALUE) { + return null; + } + if (selection === SEARCH_AGAIN_VALUE) { + continue; + } + + return selection + .split(PATH_SEPARATOR) + .map((segment) => segment.trim()) + .filter(Boolean); + } +} diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index c86071f7d8..82688cf5eb 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -4,6 +4,7 @@ import { shouldEnsureCliPath, shouldRegisterPrimarySubcommand, shouldSkipPluginCommandRegistration, + shouldUseInteractiveCommandSelector, } from "./run-main.js"; describe("rewriteUpdateFlagArgv", () => { @@ -103,6 +104,64 @@ describe("shouldSkipPluginCommandRegistration", () => { }); }); +describe("shouldUseInteractiveCommandSelector", () => { + it("enables selector for plain no-arg interactive invocations", () => { + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw"], + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true); + }); + + it("disables selector when a command is already present", () => { + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw", "status"], + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(false); + }); + + it("disables selector for non-interactive terminals or CI", () => { + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw"], + stdinIsTTY: false, + stdoutIsTTY: true, + }), + ).toBe(false); + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw"], + stdinIsTTY: true, + stdoutIsTTY: true, + ciEnv: "1", + }), + ).toBe(false); + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw"], + stdinIsTTY: true, + stdoutIsTTY: true, + disableSelectorEnv: "1", + }), + ).toBe(false); + }); + + it("disables selector for help/version invocations", () => { + expect( + shouldUseInteractiveCommandSelector({ + argv: ["node", "openclaw", "--help"], + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(false); + }); +}); + describe("shouldEnsureCliPath", () => { it("skips path bootstrap for help/version invocations", () => { expect(shouldEnsureCliPath(["node", "openclaw", "--help"])).toBe(false); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 0d0eee7825..1c235d0907 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -1,7 +1,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { loadDotEnv } from "../infra/dotenv.js"; -import { normalizeEnv } from "../infra/env.js"; +import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js"; import { formatUncaughtError } from "../infra/errors.js"; import { isMainModule } from "../infra/is-main.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; @@ -61,6 +61,28 @@ export function shouldEnsureCliPath(argv: string[]): boolean { return true; } +export function shouldUseInteractiveCommandSelector(params: { + argv: string[]; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; + ciEnv?: string; + disableSelectorEnv?: string; +}): boolean { + if (hasHelpOrVersion(params.argv)) { + return false; + } + if (getPrimaryCommand(params.argv)) { + return false; + } + if (!params.stdinIsTTY || !params.stdoutIsTTY) { + return false; + } + if (isTruthyEnvValue(params.ciEnv) || isTruthyEnvValue(params.disableSelectorEnv)) { + return false; + } + return true; +} + export async function runCli(argv: string[] = process.argv) { const normalizedArgv = normalizeWindowsArgv(argv); loadDotEnv({ quiet: true }); @@ -91,7 +113,7 @@ export async function runCli(argv: string[] = process.argv) { process.exit(1); }); - const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); + let parseArgv = rewriteUpdateFlagArgv(normalizedArgv); // Register the primary command (builtin or subcli) so help and command parsing // are correct even with lazy command registration. const primary = getPrimaryCommand(parseArgv); @@ -120,6 +142,22 @@ export async function runCli(argv: string[] = process.argv) { registerPluginCliCommands(program, loadConfig()); } + if ( + shouldUseInteractiveCommandSelector({ + argv: parseArgv, + stdinIsTTY: Boolean(process.stdin.isTTY), + stdoutIsTTY: Boolean(process.stdout.isTTY), + ciEnv: process.env.CI, + disableSelectorEnv: process.env.OPENCLAW_DISABLE_COMMAND_SELECTOR, + }) + ) { + const { runInteractiveCommandSelector } = await import("./program/command-selector.js"); + const selectedPath = await runInteractiveCommandSelector(program); + if (selectedPath && selectedPath.length > 0) { + parseArgv = [...parseArgv, ...selectedPath]; + } + } + await program.parseAsync(parseArgv); }