CLI: add explicit interactive command entrypoint

This commit is contained in:
Benjamin Jesuiter
2026-02-16 20:55:05 +01:00
parent a25156769d
commit eb87af9ea5
4 changed files with 159 additions and 19 deletions

View File

@@ -48,6 +48,9 @@ function collectCandidatesRecursive(params: {
if (isHiddenCommand(child) || child.name() === "help") {
continue;
}
if (params.parentPath.length === 0 && child.name() === "interactive") {
continue;
}
const path = [...params.parentPath, child.name()];
const label = path.join(" ");
if (!params.seen.has(label)) {

View File

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

View File

@@ -5,6 +5,7 @@ import {
shouldRegisterPrimarySubcommand,
shouldSkipPluginCommandRegistration,
shouldUseInteractiveCommandSelector,
stripInteractiveSelectorArgs,
} from "./run-main.js";
describe("rewriteUpdateFlagArgv", () => {
@@ -105,20 +106,40 @@ describe("shouldSkipPluginCommandRegistration", () => {
});
describe("shouldUseInteractiveCommandSelector", () => {
it("enables selector for plain no-arg interactive invocations", () => {
it("enables selector for -i", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw"],
argv: ["node", "openclaw", "-i"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true);
});
it("disables selector when a command is already present", () => {
it("enables selector for interactive command", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "status"],
argv: ["node", "openclaw", "interactive"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
).toBe(true);
});
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,
}),
@@ -128,14 +149,14 @@ describe("shouldUseInteractiveCommandSelector", () => {
it("disables selector for non-interactive terminals or CI", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw"],
argv: ["node", "openclaw", "-i"],
stdinIsTTY: false,
stdoutIsTTY: true,
}),
).toBe(false);
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw"],
argv: ["node", "openclaw", "-i"],
stdinIsTTY: true,
stdoutIsTTY: true,
ciEnv: "1",
@@ -143,7 +164,7 @@ describe("shouldUseInteractiveCommandSelector", () => {
).toBe(false);
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw"],
argv: ["node", "openclaw", "interactive"],
stdinIsTTY: true,
stdoutIsTTY: true,
disableSelectorEnv: "1",
@@ -154,7 +175,7 @@ describe("shouldUseInteractiveCommandSelector", () => {
it("disables selector for help/version invocations", () => {
expect(
shouldUseInteractiveCommandSelector({
argv: ["node", "openclaw", "--help"],
argv: ["node", "openclaw", "-i", "--help"],
stdinIsTTY: true,
stdoutIsTTY: true,
}),
@@ -162,6 +183,28 @@ describe("shouldUseInteractiveCommandSelector", () => {
});
});
describe("stripInteractiveSelectorArgs", () => {
it("removes -i from root invocations", () => {
expect(stripInteractiveSelectorArgs(["node", "openclaw", "-i"])).toEqual(["node", "openclaw"]);
});
it("removes interactive command from root invocations", () => {
expect(stripInteractiveSelectorArgs(["node", "openclaw", "interactive"])).toEqual([
"node",
"openclaw",
]);
});
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);

View File

@@ -61,6 +61,46 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
return true;
}
const ROOT_OPTIONS_WITH_VALUE = new Set(["--profile"]);
function parseRootInvocation(argv: string[]): {
primary: string | null;
hasInteractiveFlag: boolean;
} {
const args = argv.slice(2);
let primary: string | null = null;
let hasInteractiveFlag = false;
let expectOptionValue = false;
for (const arg of args) {
if (expectOptionValue) {
expectOptionValue = false;
continue;
}
if (arg === "--") {
break;
}
if (arg === "-i" || arg === "--interactive") {
hasInteractiveFlag = true;
continue;
}
if (arg.startsWith("--profile=")) {
continue;
}
if (ROOT_OPTIONS_WITH_VALUE.has(arg)) {
expectOptionValue = true;
continue;
}
if (arg.startsWith("-")) {
continue;
}
primary = arg;
break;
}
return { primary, hasInteractiveFlag };
}
export function shouldUseInteractiveCommandSelector(params: {
argv: string[];
stdinIsTTY: boolean;
@@ -71,7 +111,14 @@ export function shouldUseInteractiveCommandSelector(params: {
if (hasHelpOrVersion(params.argv)) {
return false;
}
if (getPrimaryCommand(params.argv)) {
const root = parseRootInvocation(params.argv);
const requestedViaCommand = root.primary === "interactive";
if (!root.hasInteractiveFlag && !requestedViaCommand) {
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 && root.primary !== "interactive") {
return false;
}
if (!params.stdinIsTTY || !params.stdoutIsTTY) {
@@ -83,6 +130,49 @@ export function shouldUseInteractiveCommandSelector(params: {
return true;
}
export function stripInteractiveSelectorArgs(argv: string[]): string[] {
const args = argv.slice(2);
const next: string[] = [];
let sawPrimary = false;
let expectOptionValue = 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") {
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("-")) {
sawPrimary = true;
if (arg === "interactive") {
continue;
}
}
}
next.push(arg);
}
return [...argv.slice(0, 2), ...next];
}
export async function runCli(argv: string[] = process.argv) {
const normalizedArgv = normalizeWindowsArgv(argv);
loadDotEnv({ quiet: true });
@@ -114,6 +204,17 @@ export async function runCli(argv: string[] = process.argv) {
});
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);
@@ -142,15 +243,7 @@ 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,
})
) {
if (useInteractiveSelector) {
const { runInteractiveCommandSelector } = await import("./program/command-selector.js");
const selectedPath = await runInteractiveCommandSelector(program);
if (selectedPath && selectedPath.length > 0) {