mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
CLI: add explicit interactive command entrypoint
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user