Compare commits
11 Commits
secrets/pr
...
bjesuiter/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47dbf9beda | ||
|
|
d8812d5590 | ||
|
|
e78f25d05a | ||
|
|
0be8e6e3e4 | ||
|
|
f0ef3f4897 | ||
|
|
3b2e145587 | ||
|
|
92e1e87034 | ||
|
|
bde982ae7c | ||
|
|
eb87af9ea5 | ||
|
|
a25156769d | ||
|
|
3e9e9258a4 |
62
src/cli/program/command-questionnaire.test.ts
Normal file
62
src/cli/program/command-questionnaire.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOptionalParameterEntries,
|
||||
isRequiredOption,
|
||||
preferredOptionFlag,
|
||||
shouldPromptForOption,
|
||||
splitMultiValueInput,
|
||||
} from "./command-questionnaire.js";
|
||||
|
||||
describe("command-questionnaire", () => {
|
||||
it("splits multi-value input by spaces and commas", () => {
|
||||
expect(splitMultiValueInput("a b, c,,d")).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("prefers long option flags", () => {
|
||||
const option = new Option("-p, --provider <name>");
|
||||
expect(preferredOptionFlag(option)).toBe("--provider");
|
||||
});
|
||||
|
||||
it("falls back to short flag when long is absent", () => {
|
||||
const option = new Option("-f");
|
||||
expect(preferredOptionFlag(option)).toBe("-f");
|
||||
});
|
||||
|
||||
it("skips internal and hidden options", () => {
|
||||
expect(shouldPromptForOption(new Option("-h, --help"))).toBe(false);
|
||||
expect(shouldPromptForOption(new Option("-V, --version"))).toBe(false);
|
||||
expect(shouldPromptForOption(new Option("-i, --interactive"))).toBe(false);
|
||||
|
||||
const hidden = new Option("--secret");
|
||||
hidden.hideHelp(true);
|
||||
expect(shouldPromptForOption(hidden)).toBe(false);
|
||||
});
|
||||
|
||||
it("prompts for regular options", () => {
|
||||
expect(shouldPromptForOption(new Option("--provider <name>"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects required options", () => {
|
||||
const required = new Option("--provider <name>").makeOptionMandatory(true);
|
||||
const optional = new Option("--verbose");
|
||||
|
||||
expect(isRequiredOption(required)).toBe(true);
|
||||
expect(isRequiredOption(optional)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds optional parameter entries from optional options and arguments", () => {
|
||||
const command = new Command("demo")
|
||||
.argument("<target>")
|
||||
.argument("[note]")
|
||||
.addOption(new Option("--provider <name>").makeOptionMandatory(true))
|
||||
.option("--verbose", "Verbose output");
|
||||
|
||||
const entries = buildOptionalParameterEntries(command);
|
||||
|
||||
expect(entries.map((entry) => entry.label)).toContain("--verbose");
|
||||
expect(entries.map((entry) => entry.label)).toContain("[note]");
|
||||
expect(entries.map((entry) => entry.label)).not.toContain("--provider");
|
||||
expect(entries.map((entry) => entry.label)).not.toContain("<target>");
|
||||
});
|
||||
});
|
||||
330
src/cli/program/command-questionnaire.ts
Normal file
330
src/cli/program/command-questionnaire.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { Argument, Command, Option } from "commander";
|
||||
import {
|
||||
isCancel,
|
||||
multiselect as clackMultiselect,
|
||||
select as clackSelect,
|
||||
text as clackText,
|
||||
} from "@clack/prompts";
|
||||
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
|
||||
import { resolveCommandByPath } from "./command-selector.js";
|
||||
|
||||
const INTERNAL_OPTION_NAMES = new Set(["help", "version", "interactive"]);
|
||||
|
||||
type PromptResult = string[] | null;
|
||||
|
||||
type OptionalParameterEntry =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
kind: "option";
|
||||
option: Option;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
kind: "argument";
|
||||
argument: Argument;
|
||||
};
|
||||
|
||||
export function splitMultiValueInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
export function preferredOptionFlag(option: Option): string {
|
||||
return option.long ?? option.short ?? option.flags.split(/[ ,|]+/)[0] ?? option.flags;
|
||||
}
|
||||
|
||||
export function shouldPromptForOption(option: Option): boolean {
|
||||
if (option.hidden) {
|
||||
return false;
|
||||
}
|
||||
return !INTERNAL_OPTION_NAMES.has(option.name());
|
||||
}
|
||||
|
||||
export function isRequiredOption(option: Option): boolean {
|
||||
return shouldPromptForOption(option) && option.mandatory;
|
||||
}
|
||||
|
||||
function formatArgumentLabel(argument: Argument): string {
|
||||
const wrapped = argument.required ? `<${argument.name()}>` : `[${argument.name()}]`;
|
||||
return argument.variadic ? wrapped.replace(/([\]>])$/, "...$1") : wrapped;
|
||||
}
|
||||
|
||||
function buildArgumentHint(argument: Argument): string {
|
||||
if (argument.description) {
|
||||
return argument.description;
|
||||
}
|
||||
return argument.required ? "Required argument" : "Optional argument";
|
||||
}
|
||||
|
||||
function buildOptionHint(option: Option): string {
|
||||
const desc = option.description?.trim();
|
||||
if (desc) {
|
||||
return desc;
|
||||
}
|
||||
return option.mandatory ? "Required option" : "Optional option";
|
||||
}
|
||||
|
||||
export function buildOptionalParameterEntries(command: Command): OptionalParameterEntry[] {
|
||||
const entries: OptionalParameterEntry[] = [];
|
||||
|
||||
for (const option of command.options) {
|
||||
if (!shouldPromptForOption(option) || option.mandatory) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
id: `opt:${option.attributeName()}`,
|
||||
label: preferredOptionFlag(option),
|
||||
hint: buildOptionHint(option),
|
||||
kind: "option",
|
||||
option,
|
||||
});
|
||||
}
|
||||
|
||||
command.registeredArguments.forEach((argument, index) => {
|
||||
if (argument.required) {
|
||||
return;
|
||||
}
|
||||
entries.push({
|
||||
id: `arg:${index}:${argument.name()}`,
|
||||
label: formatArgumentLabel(argument),
|
||||
hint: buildArgumentHint(argument),
|
||||
kind: "argument",
|
||||
argument,
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function askValue(params: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}): Promise<string | null> {
|
||||
const value = await clackText({
|
||||
message: stylePromptMessage(params.message) ?? params.message,
|
||||
placeholder: params.placeholder,
|
||||
validate: params.required
|
||||
? (input) => {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return "Value required";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
if (isCancel(value)) {
|
||||
return null;
|
||||
}
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
async function askChoice(params: {
|
||||
message: string;
|
||||
choices: readonly string[];
|
||||
hint?: string;
|
||||
}): Promise<string | null> {
|
||||
const choice = await clackSelect<string>({
|
||||
message: stylePromptMessage(params.message) ?? params.message,
|
||||
options: params.choices.map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
hint: params.hint ? stylePromptHint(params.hint) : undefined,
|
||||
})),
|
||||
});
|
||||
if (isCancel(choice)) {
|
||||
return null;
|
||||
}
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function promptArgumentValue(argument: Argument, required: boolean): Promise<PromptResult> {
|
||||
const label = argument.name();
|
||||
const suffix = argument.description ? ` — ${argument.description}` : "";
|
||||
|
||||
if (argument.argChoices && argument.argChoices.length > 0 && !argument.variadic) {
|
||||
const choice = await askChoice({
|
||||
message: `Select value for <${label}>`,
|
||||
choices: argument.argChoices,
|
||||
hint: argument.description,
|
||||
});
|
||||
return choice === null ? null : [choice];
|
||||
}
|
||||
|
||||
if (argument.variadic) {
|
||||
const raw = await askValue({
|
||||
message: `Values for <${label}...> (space/comma-separated)${suffix}`,
|
||||
placeholder: required ? "value1 value2" : "optional",
|
||||
required,
|
||||
});
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const values = splitMultiValueInput(raw);
|
||||
if (required && values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const value = await askValue({
|
||||
message: `Value for <${label}>${suffix}`,
|
||||
required,
|
||||
});
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!value && !required) {
|
||||
return [];
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
async function promptOptionValue(option: Option, required: boolean): Promise<PromptResult> {
|
||||
const flag = preferredOptionFlag(option);
|
||||
const description = option.description ? ` — ${option.description}` : "";
|
||||
|
||||
if (option.isBoolean()) {
|
||||
// Required booleans imply the flag must be set.
|
||||
// Optional booleans are only prompted when selected in the optional multiselect.
|
||||
return [flag];
|
||||
}
|
||||
|
||||
if (option.argChoices && option.argChoices.length > 0 && !option.variadic) {
|
||||
const choice = await askChoice({
|
||||
message: `Select value for ${flag}`,
|
||||
choices: option.argChoices,
|
||||
hint: option.description,
|
||||
});
|
||||
return choice === null ? null : [flag, choice];
|
||||
}
|
||||
|
||||
if (option.optional) {
|
||||
const raw = await askValue({
|
||||
message: `Optional value for ${flag} (leave empty for flag only)${description}`,
|
||||
required: false,
|
||||
});
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (required && raw.length === 0) {
|
||||
return [flag];
|
||||
}
|
||||
return raw.length > 0 ? [flag, raw] : [flag];
|
||||
}
|
||||
|
||||
if (option.variadic) {
|
||||
const raw = await askValue({
|
||||
message: `Values for ${flag} (space/comma-separated)${description}`,
|
||||
placeholder: "value1 value2",
|
||||
required,
|
||||
});
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const values = splitMultiValueInput(raw);
|
||||
if (values.length === 0) {
|
||||
return required ? null : [flag];
|
||||
}
|
||||
const tokens: string[] = [];
|
||||
for (const value of values) {
|
||||
tokens.push(flag, value);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const value = await askValue({
|
||||
message: `Value for ${flag}${description}`,
|
||||
required,
|
||||
});
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!value && !required) {
|
||||
return [];
|
||||
}
|
||||
return [flag, value];
|
||||
}
|
||||
|
||||
export async function runCommandQuestionnaire(params: {
|
||||
program: Command;
|
||||
commandPath: string[];
|
||||
}): Promise<string[] | null> {
|
||||
const command = resolveCommandByPath(params.program, params.commandPath);
|
||||
if (!command) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const optionTokens: string[] = [];
|
||||
const argumentTokens: string[] = [];
|
||||
|
||||
// 1) Ask only required parameters first.
|
||||
for (const argument of command.registeredArguments) {
|
||||
if (!argument.required) {
|
||||
continue;
|
||||
}
|
||||
const tokens = await promptArgumentValue(argument, true);
|
||||
if (tokens === null) {
|
||||
return null;
|
||||
}
|
||||
argumentTokens.push(...tokens);
|
||||
}
|
||||
|
||||
for (const option of command.options) {
|
||||
if (!isRequiredOption(option)) {
|
||||
continue;
|
||||
}
|
||||
const tokens = await promptOptionValue(option, true);
|
||||
if (tokens === null) {
|
||||
return null;
|
||||
}
|
||||
optionTokens.push(...tokens);
|
||||
}
|
||||
|
||||
// 2) Then let user pick optional parameters to activate.
|
||||
const optionalEntries = buildOptionalParameterEntries(command);
|
||||
if (optionalEntries.length > 0) {
|
||||
const selected = await clackMultiselect<string>({
|
||||
message:
|
||||
stylePromptMessage("Select optional parameters to set") ??
|
||||
"Select optional parameters to set",
|
||||
options: optionalEntries.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.label,
|
||||
hint: entry.hint ? stylePromptHint(entry.hint) : undefined,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
if (isCancel(selected)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedIds = new Set(Array.isArray(selected) ? selected : []);
|
||||
for (const entry of optionalEntries) {
|
||||
if (!selectedIds.has(entry.id)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "option") {
|
||||
const tokens = await promptOptionValue(entry.option, false);
|
||||
if (tokens === null) {
|
||||
return null;
|
||||
}
|
||||
optionTokens.push(...tokens);
|
||||
continue;
|
||||
}
|
||||
const tokens = await promptArgumentValue(entry.argument, true);
|
||||
if (tokens === null) {
|
||||
return null;
|
||||
}
|
||||
argumentTokens.push(...tokens);
|
||||
}
|
||||
}
|
||||
|
||||
return [...optionTokens, ...argumentTokens];
|
||||
}
|
||||
120
src/cli/program/command-selector.test.ts
Normal file
120
src/cli/program/command-selector.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectCommandSelectorCandidates,
|
||||
collectDirectSubcommandSelectorCandidates,
|
||||
commandRequiresSubcommand,
|
||||
rankCommandSelectorCandidates,
|
||||
resolveCommandByPath,
|
||||
} 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);
|
||||
});
|
||||
|
||||
it("prioritizes deep commands when querying a shared subcommand name", () => {
|
||||
const program = new Command();
|
||||
const models = program.command("models").description("Model commands");
|
||||
const aliases = models.command("aliases").description("Alias commands");
|
||||
aliases.command("add").description("Add alias");
|
||||
const fallbacks = models.command("fallbacks").description("Fallback commands");
|
||||
fallbacks.command("add").description("Add fallback");
|
||||
|
||||
const candidates = collectCommandSelectorCandidates(program);
|
||||
const ranked = rankCommandSelectorCandidates(candidates, "add");
|
||||
const topLabels = ranked.slice(0, 2).map((candidate) => candidate.label);
|
||||
|
||||
expect(topLabels).toEqual(["models aliases add", "models fallbacks add"]);
|
||||
const aliasesParentIndex = ranked.findIndex(
|
||||
(candidate) => candidate.label === "models aliases",
|
||||
);
|
||||
const aliasesAddIndex = ranked.findIndex(
|
||||
(candidate) => candidate.label === "models aliases add",
|
||||
);
|
||||
expect(aliasesParentIndex).toBeGreaterThan(aliasesAddIndex);
|
||||
});
|
||||
|
||||
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");
|
||||
const auth = models
|
||||
.command("auth")
|
||||
.description("Auth command")
|
||||
.action(() => undefined);
|
||||
auth.command("login").description("Login auth profile");
|
||||
|
||||
const status = program
|
||||
.command("status")
|
||||
.description("Status")
|
||||
.action(() => undefined);
|
||||
|
||||
expect(commandRequiresSubcommand(models)).toBe(true);
|
||||
expect(commandRequiresSubcommand(auth)).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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
345
src/cli/program/command-selector.ts
Normal file
345
src/cli/program/command-selector.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { autocomplete as clackAutocomplete, isCancel } from "@clack/prompts";
|
||||
import type { Command } from "commander";
|
||||
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.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 SHOW_HELP_VALUE = "__show_help__";
|
||||
const BACK_TO_MAIN_VALUE = "__back_to_main__";
|
||||
const RUN_CURRENT_VALUE = "__run_current__";
|
||||
const PATH_SEPARATOR = "\u0000";
|
||||
const SELECTION_VALUE_SEPARATOR = "\u0001";
|
||||
const MAX_RESULTS = 200;
|
||||
|
||||
type CommandSelectorCandidate = {
|
||||
path: string[];
|
||||
label: string;
|
||||
description: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
type PreparedCommandSelectorCandidate = CommandSelectorCandidate & {
|
||||
searchTextLower: string;
|
||||
};
|
||||
|
||||
type SelectorPromptResult = string[] | "back_to_main" | "run_current" | 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): boolean {
|
||||
return isHiddenCommand(command) || command.name() === "help";
|
||||
}
|
||||
|
||||
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 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[];
|
||||
seen: Set<string>;
|
||||
out: CommandSelectorCandidate[];
|
||||
}): void {
|
||||
for (const child of params.command.commands) {
|
||||
if (shouldSkipCommand(child)) {
|
||||
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<string>();
|
||||
const raw: CommandSelectorCandidate[] = [];
|
||||
collectCandidatesRecursive({ command: program, parentPath: [], seen, out: raw });
|
||||
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;
|
||||
}
|
||||
|
||||
export function commandRequiresSubcommand(command: Command): boolean {
|
||||
return command.commands.some((child) => !shouldSkipCommand(child));
|
||||
}
|
||||
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
const path = [...basePath, child.name()];
|
||||
raw.push({
|
||||
path,
|
||||
label: child.name(),
|
||||
description: resolveCommandDescription(child),
|
||||
searchText: `${child.name()} ${path.join(" ")}`,
|
||||
});
|
||||
}
|
||||
return prepareSortedCandidates(raw);
|
||||
}
|
||||
|
||||
function prioritizeDeepCommandsForSubcommandQuery(params: {
|
||||
ranked: PreparedCommandSelectorCandidate[];
|
||||
queryLower: string;
|
||||
}): PreparedCommandSelectorCandidate[] {
|
||||
const tokens = params.queryLower.split(/\s+/).filter((token) => token.length > 0);
|
||||
if (tokens.length !== 1) {
|
||||
return params.ranked;
|
||||
}
|
||||
const [token] = tokens;
|
||||
if (!token) {
|
||||
return params.ranked;
|
||||
}
|
||||
|
||||
const deepExact: PreparedCommandSelectorCandidate[] = [];
|
||||
const remaining: PreparedCommandSelectorCandidate[] = [];
|
||||
for (const candidate of params.ranked) {
|
||||
const last = candidate.path[candidate.path.length - 1]?.toLowerCase();
|
||||
if (candidate.path.length >= 2 && last === token) {
|
||||
deepExact.push(candidate);
|
||||
continue;
|
||||
}
|
||||
remaining.push(candidate);
|
||||
}
|
||||
|
||||
if (deepExact.length === 0) {
|
||||
return params.ranked;
|
||||
}
|
||||
return [...deepExact, ...remaining];
|
||||
}
|
||||
|
||||
export function rankCommandSelectorCandidates(
|
||||
candidates: PreparedCommandSelectorCandidate[],
|
||||
query: string,
|
||||
): PreparedCommandSelectorCandidate[] {
|
||||
const queryLower = query.trim().toLowerCase();
|
||||
if (!queryLower) {
|
||||
return candidates;
|
||||
}
|
||||
const ranked = fuzzyFilterLower(candidates, queryLower);
|
||||
return prioritizeDeepCommandsForSubcommandQuery({ ranked, queryLower });
|
||||
}
|
||||
|
||||
async function hydrateProgramCommandsForSelector(program: Command): Promise<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializePath(path: string[]): string {
|
||||
return path.join(PATH_SEPARATOR);
|
||||
}
|
||||
|
||||
function deserializePath(value: string): string[] {
|
||||
return value
|
||||
.split(PATH_SEPARATOR)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function serializeSelectionValue(params: { path: string[]; query: string }): string {
|
||||
return `${params.query}${SELECTION_VALUE_SEPARATOR}${serializePath(params.path)}`;
|
||||
}
|
||||
|
||||
function deserializeSelectionPath(value: string): string[] {
|
||||
const separatorIndex = value.indexOf(SELECTION_VALUE_SEPARATOR);
|
||||
const pathValue = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
|
||||
return deserializePath(pathValue);
|
||||
}
|
||||
|
||||
async function promptForCommandSelection(params: {
|
||||
message: string;
|
||||
placeholder: string;
|
||||
candidates: PreparedCommandSelectorCandidate[];
|
||||
includeBackToMain?: boolean;
|
||||
includeRunCurrent?: boolean;
|
||||
currentPath?: string[];
|
||||
}): Promise<SelectorPromptResult> {
|
||||
const selection = await clackAutocomplete<string>({
|
||||
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(params.candidates, query).slice(0, MAX_RESULTS);
|
||||
return [
|
||||
...ranked.map((candidate) => ({
|
||||
value: serializeSelectionValue({ path: candidate.path, query }),
|
||||
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
|
||||
? [
|
||||
{
|
||||
value: BACK_TO_MAIN_VALUE,
|
||||
label: "../",
|
||||
hint: stylePromptHint("Back to main command selector"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
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 === BACK_TO_MAIN_VALUE) {
|
||||
return "back_to_main";
|
||||
}
|
||||
if (selection === RUN_CURRENT_VALUE) {
|
||||
return "run_current";
|
||||
}
|
||||
return deserializeSelectionPath(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" || mainSelection === "run_current") {
|
||||
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,
|
||||
includeRunCurrent: true,
|
||||
currentPath: selectedPath,
|
||||
includeBackToMain: true,
|
||||
});
|
||||
if (!subSelection) {
|
||||
return null;
|
||||
}
|
||||
if (subSelection === "back_to_main") {
|
||||
break;
|
||||
}
|
||||
if (subSelection === "run_current") {
|
||||
return selectedPath;
|
||||
}
|
||||
|
||||
selectedPath = subSelection;
|
||||
selectedCommand = resolveCommandByPath(program, selectedPath);
|
||||
if (!selectedCommand || !commandRequiresSubcommand(selectedCommand)) {
|
||||
return selectedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
||||
.option(
|
||||
"--profile <name>",
|
||||
"Use a named profile (isolates OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH under ~/.openclaw-<name>)",
|
||||
);
|
||||
)
|
||||
.option("-i, --interactive", "Open interactive command selector");
|
||||
|
||||
program.option("--no-color", "Disable ANSI colors", false);
|
||||
program.helpOption("-h, --help", "Display help for command");
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isCommanderExitError,
|
||||
rewriteUpdateFlagArgv,
|
||||
scanInteractiveRootArgv,
|
||||
shouldEnsureCliPath,
|
||||
shouldRegisterPrimarySubcommand,
|
||||
shouldSkipPluginCommandRegistration,
|
||||
shouldUseInteractiveCommandSelector,
|
||||
stripInteractiveSelectorArgs,
|
||||
} from "./run-main.js";
|
||||
|
||||
describe("isCommanderExitError", () => {
|
||||
it("detects commander exit errors", () => {
|
||||
expect(isCommanderExitError({ code: "commander.helpDisplayed" })).toBe(true);
|
||||
expect(isCommanderExitError({ code: "commander.unknownOption" })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-commander errors", () => {
|
||||
expect(isCommanderExitError(new Error("boom"))).toBe(false);
|
||||
expect(isCommanderExitError({ code: "custom.error" })).toBe(false);
|
||||
expect(isCommanderExitError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteUpdateFlagArgv", () => {
|
||||
it("leaves argv unchanged when --update is absent", () => {
|
||||
const argv = ["node", "entry.js", "status"];
|
||||
@@ -103,6 +120,138 @@ describe("shouldSkipPluginCommandRegistration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanInteractiveRootArgv", () => {
|
||||
it("extracts interactive flag, primary, and stripped argv in one pass", () => {
|
||||
expect(
|
||||
scanInteractiveRootArgv(["node", "openclaw", "-i", "--profile", "dev"]).hasInteractiveFlag,
|
||||
).toBe(true);
|
||||
expect(
|
||||
scanInteractiveRootArgv(["node", "openclaw", "-i", "--profile", "dev"]).primary,
|
||||
).toBeNull();
|
||||
expect(
|
||||
scanInteractiveRootArgv(["node", "openclaw", "-i", "--profile", "dev"]).strippedArgv,
|
||||
).toEqual(["node", "openclaw", "--profile", "dev"]);
|
||||
});
|
||||
|
||||
it("detects primary commands while stripping interactive flags", () => {
|
||||
const scanned = scanInteractiveRootArgv(["node", "openclaw", "-i", "status", "--json"]);
|
||||
expect(scanned.hasInteractiveFlag).toBe(true);
|
||||
expect(scanned.primary).toBe("status");
|
||||
expect(scanned.strippedArgv).toEqual(["node", "openclaw", "status", "--json"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseInteractiveCommandSelector", () => {
|
||||
it("enables selector for -i", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "-i"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("enables selector for --interactive", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "--interactive"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enable selector for interactive command name", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "interactive"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps default no-arg invocation on fast help path", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores -i when a real command is already present", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "-i", "status"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disables selector for non-interactive terminals or CI", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "-i"],
|
||||
stdinIsTTY: false,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "-i"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
ciEnv: "1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "--interactive"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
disableSelectorEnv: "1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disables selector for help/version invocations", () => {
|
||||
expect(
|
||||
shouldUseInteractiveCommandSelector({
|
||||
argv: ["node", "openclaw", "-i", "--help"],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripInteractiveSelectorArgs", () => {
|
||||
it("removes -i from root invocations", () => {
|
||||
expect(stripInteractiveSelectorArgs(["node", "openclaw", "-i"])).toEqual(["node", "openclaw"]);
|
||||
});
|
||||
|
||||
it("keeps non-flag command arguments unchanged", () => {
|
||||
expect(stripInteractiveSelectorArgs(["node", "openclaw", "interactive"])).toEqual([
|
||||
"node",
|
||||
"openclaw",
|
||||
"interactive",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unrelated arguments", () => {
|
||||
expect(stripInteractiveSelectorArgs(["node", "openclaw", "--profile", "dev", "-i"])).toEqual([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--profile",
|
||||
"dev",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldEnsureCliPath", () => {
|
||||
it("skips path bootstrap for help/version invocations", () => {
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "--help"])).toBe(false);
|
||||
|
||||
@@ -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,100 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ROOT_OPTIONS_WITH_VALUE = new Set(["--profile"]);
|
||||
|
||||
export function scanInteractiveRootArgv(argv: string[]): {
|
||||
primary: string | null;
|
||||
hasInteractiveFlag: boolean;
|
||||
strippedArgv: string[];
|
||||
} {
|
||||
const args = argv.slice(2);
|
||||
const next: string[] = [];
|
||||
let primary: string | null = null;
|
||||
let hasInteractiveFlag = false;
|
||||
let expectOptionValue = false;
|
||||
let sawPrimary = false;
|
||||
|
||||
for (const arg of args) {
|
||||
if (!sawPrimary) {
|
||||
if (expectOptionValue) {
|
||||
expectOptionValue = false;
|
||||
next.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
sawPrimary = true;
|
||||
next.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "-i" || arg === "--interactive") {
|
||||
hasInteractiveFlag = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--profile=")) {
|
||||
next.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (ROOT_OPTIONS_WITH_VALUE.has(arg)) {
|
||||
expectOptionValue = true;
|
||||
next.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
primary = arg;
|
||||
sawPrimary = true;
|
||||
}
|
||||
}
|
||||
next.push(arg);
|
||||
}
|
||||
|
||||
return {
|
||||
primary,
|
||||
hasInteractiveFlag,
|
||||
strippedArgv: [...argv.slice(0, 2), ...next],
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseInteractiveCommandSelector(params: {
|
||||
argv: string[];
|
||||
stdinIsTTY: boolean;
|
||||
stdoutIsTTY: boolean;
|
||||
ciEnv?: string;
|
||||
disableSelectorEnv?: string;
|
||||
}): boolean {
|
||||
if (hasHelpOrVersion(params.argv)) {
|
||||
return false;
|
||||
}
|
||||
const root = scanInteractiveRootArgv(params.argv);
|
||||
if (!root.hasInteractiveFlag) {
|
||||
return false;
|
||||
}
|
||||
// Keep -i as an explicit interactive entrypoint only for root invocations.
|
||||
// If a real command is already present, run it normally and ignore -i.
|
||||
if (root.primary) {
|
||||
return false;
|
||||
}
|
||||
if (!params.stdinIsTTY || !params.stdoutIsTTY) {
|
||||
return false;
|
||||
}
|
||||
if (isTruthyEnvValue(params.ciEnv) || isTruthyEnvValue(params.disableSelectorEnv)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stripInteractiveSelectorArgs(argv: string[]): string[] {
|
||||
return scanInteractiveRootArgv(argv).strippedArgv;
|
||||
}
|
||||
|
||||
export function isCommanderExitError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === "string" && code.startsWith("commander.");
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[] = process.argv) {
|
||||
const normalizedArgv = normalizeWindowsArgv(argv);
|
||||
loadDotEnv({ quiet: true });
|
||||
@@ -91,7 +185,18 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
|
||||
let parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
|
||||
const useInteractiveSelector = shouldUseInteractiveCommandSelector({
|
||||
argv: parseArgv,
|
||||
stdinIsTTY: Boolean(process.stdin.isTTY),
|
||||
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
||||
ciEnv: process.env.CI,
|
||||
disableSelectorEnv: process.env.OPENCLAW_DISABLE_COMMAND_SELECTOR,
|
||||
});
|
||||
if (useInteractiveSelector) {
|
||||
parseArgv = stripInteractiveSelectorArgs(parseArgv);
|
||||
}
|
||||
|
||||
// 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 +225,38 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
registerPluginCliCommands(program, loadConfig());
|
||||
}
|
||||
|
||||
if (useInteractiveSelector) {
|
||||
const interactiveBaseArgv = parseArgv;
|
||||
const { runInteractiveCommandSelector } = await import("./program/command-selector.js");
|
||||
const { runCommandQuestionnaire } = await import("./program/command-questionnaire.js");
|
||||
|
||||
// In interactive mode we keep the process alive and return to the main menu
|
||||
// after each command run (or handled command-parse/help exit).
|
||||
program.exitOverride();
|
||||
while (true) {
|
||||
const selectedPath = await runInteractiveCommandSelector(program);
|
||||
if (!selectedPath || selectedPath.length === 0) {
|
||||
// Exit silently when leaving interactive mode.
|
||||
return;
|
||||
}
|
||||
|
||||
const promptArgs = await runCommandQuestionnaire({ program, commandPath: selectedPath });
|
||||
if (promptArgs === null) {
|
||||
// User cancelled parameter entry: return to the main picker.
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandArgv = [...interactiveBaseArgv, ...selectedPath, ...promptArgs];
|
||||
try {
|
||||
await program.parseAsync(commandArgv);
|
||||
} catch (error) {
|
||||
if (!isCommanderExitError(error)) {
|
||||
console.error("[openclaw] Command failed:", formatUncaughtError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await program.parseAsync(parseArgv);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user