From 433bb3f954ed17263fe585141f94153dedfcc569 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 16:20:34 -0700 Subject: [PATCH] fix(cli): make commands, completion, and JSON output reliable (#116033) * fix(cli): make commands, completion, and JSON output reliable * fix(cli): reconcile completion coverage with current main * test(cli): keep test routing stable across isolation lanes --- .../register.batch.test.ts | 17 + .../register.batch.ts | 5 + .../register.form-wait-eval.test.ts | 16 + .../browser-cli-actions-input/shared.test.ts | 16 +- .../cli/browser-cli-actions-input/shared.ts | 6 + .../src/cli/browser-cli-inspect.test.ts | 20 + .../browser/src/cli/browser-cli-inspect.ts | 34 +- .../src/cli/browser-cli-manage.test.ts | 15 + .../browser/src/cli/browser-cli-manage.ts | 7 + extensions/memory-lancedb/memory-cli.test.ts | 20 + extensions/memory-lancedb/memory-cli.ts | 2 +- src/cli/channels-cli.test.ts | 34 + src/cli/channels-cli.ts | 10 +- src/cli/command-options.test.ts | 68 +- src/cli/completion-cli.aliases.test.ts | 144 ++++ src/cli/completion-cli.test-support.ts | 133 ++++ src/cli/completion-cli.test.ts | 658 ++++++++++-------- src/cli/completion-cli.ts | 60 +- src/cli/config-cli-path.ts | 33 +- src/cli/config-cli.test.ts | 131 ++++ src/cli/cron-cli.test.ts | 14 + src/cli/cron-cli/register.cron-add.ts | 3 + src/cli/cron-cli/register.cron-simple.test.ts | 34 + src/cli/cron-cli/register.cron-simple.ts | 3 + src/cli/cron-cli/shared.ts | 2 +- src/cli/daemon-cli/status.test.ts | 80 +++ src/cli/daemon-cli/status.ts | 37 +- src/cli/devices-cli.runtime.ts | 12 +- src/cli/devices-cli.test.ts | 23 + src/cli/failure-output.test.ts | 27 + src/cli/failure-output.ts | 11 +- src/cli/gateway-cli.coverage.test.ts | 64 ++ src/cli/gateway-cli/register.ts | 197 +++--- src/cli/gateway-rpc.runtime.test.ts | 45 ++ src/cli/gateway-rpc.runtime.ts | 1 + src/cli/gateway-rpc.ts | 1 + src/cli/gateway-rpc.types.ts | 1 + src/cli/hooks-cli.toggle.test.ts | 234 +++++++ src/cli/hooks-cli.ts | 21 +- src/cli/nodes-cli.coverage.test.ts | 33 + src/cli/nodes-cli/register.pairing.ts | 33 +- src/cli/pairing-cli.test.ts | 16 + src/cli/pairing-cli.ts | 9 + src/cli/plugins-cli.lazy.test.ts | 41 ++ src/cli/plugins-cli.ts | 1 + src/cli/program/action-reparse.test.ts | 109 +++ src/cli/program/action-reparse.ts | 25 +- src/cli/program/message/helpers.test.ts | 23 + src/cli/program/message/helpers.ts | 3 + src/cli/program/register.agent.test.ts | 11 + src/cli/program/register.agent.ts | 1 + src/cli/program/register.message.test.ts | 53 +- src/cli/program/register.message.ts | 13 +- .../register.status-health-sessions.test.ts | 33 + .../register.status-health-sessions.ts | 121 ++-- src/cli/progress.test.ts | 83 +++ src/cli/progress.ts | 8 +- src/cli/skills-cli.ts | 3 + src/cli/skills-cli.workshop.test.ts | 26 + src/commands/doctor-post-upgrade.test.ts | 135 +++- src/commands/doctor-post-upgrade.ts | 34 +- src/commands/models/aliases.test.ts | 68 ++ src/commands/models/aliases.ts | 25 +- src/commands/models/fallbacks-shared.test.ts | 60 ++ src/commands/models/fallbacks-shared.ts | 11 +- src/commands/models/scan.test.ts | 55 ++ src/commands/models/scan.ts | 11 +- src/commands/status.service-summary.test.ts | 32 + src/commands/status.service-summary.ts | 4 +- test/scripts/test-projects.test.ts | 21 +- 70 files changed, 2789 insertions(+), 551 deletions(-) create mode 100644 src/cli/completion-cli.aliases.test.ts create mode 100644 src/cli/completion-cli.test-support.ts create mode 100644 src/cli/hooks-cli.toggle.test.ts create mode 100644 src/commands/models/fallbacks-shared.test.ts diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts index 838a8a3d6fe7..ff32543afba6 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.test.ts @@ -160,6 +160,23 @@ describe("browser action input batch command", () => { expect(getLastActionBody()).toMatchObject({ kind: "batch", actions: SAMPLE_ACTIONS }); }); + it("rejects conflicting inline and file actions before reading either source", async () => { + const program = createActionInputProgram(); + + await expect( + program.parseAsync( + ["browser", "batch", "--actions", "[]", "--actions-file", "/tmp/browser-actions.json"], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "Specify only one of --actions or --actions-file", + ); + expect(mocks.readActionsPayload).not.toHaveBeenCalled(); + expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); + }); + it("rejects malformed actions JSON before dispatch", async () => { mocks.readActionsPayload.mockResolvedValueOnce("NOT JSON {{{"); const program = createActionInputProgram(); diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts index 27787fabb2d3..55a75c06089b 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.batch.ts @@ -28,6 +28,11 @@ export function registerBrowserBatchCommands( .option("--target-id ", BROWSER_TAB_REFERENCE_HELP) .action(async (opts, cmd) => { const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts); + if (opts.actions !== undefined && opts.actionsFile !== undefined) { + defaultRuntime.error(danger("Specify only one of --actions or --actions-file")); + defaultRuntime.exit(1); + return; + } if (!opts.actions && !opts.actionsFile) { defaultRuntime.error(danger("Provide --actions, --actions-file, or --actions-file -")); defaultRuntime.exit(1); diff --git a/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.test.ts b/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.test.ts index 43f66afd0cef..c79e6f7885b5 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.test.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/register.form-wait-eval.test.ts @@ -84,6 +84,22 @@ describe("browser action input fill command", () => { ); expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); }); + + it("rejects conflicting inline and file fields before dispatch", async () => { + const program = createActionInputProgram(); + + await expect( + program.parseAsync( + ["browser", "fill", "--fields", "[]", "--fields-file", "/tmp/browser-fields.json"], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain( + "Specify only one of --fields or --fields-file", + ); + expect(mocks.callBrowserRequest).not.toHaveBeenCalled(); + }); }); describe("browser action input wait command", () => { diff --git a/extensions/browser/src/cli/browser-cli-actions-input/shared.test.ts b/extensions/browser/src/cli/browser-cli-actions-input/shared.test.ts index 884b55524df3..73575aa369fe 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/shared.test.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/shared.test.ts @@ -1,6 +1,6 @@ // Browser tests cover shared plugin behavior. import { describe, expect, it } from "vitest"; -import { readFields } from "./shared.js"; +import { readActionsPayload, readFields } from "./shared.js"; describe("readFields", () => { it.each([ @@ -38,4 +38,18 @@ describe("readFields", () => { it("throws descriptive error on empty fields", async () => { await expect(readFields({ fields: "" })).rejects.toThrow("fields are required"); }); + + it("rejects conflicting inline and file form fields", async () => { + await expect( + readFields({ fields: "[]", fieldsFile: "/tmp/openclaw-browser-fields.json" }), + ).rejects.toThrow("Specify only one of --fields or --fields-file"); + }); +}); + +describe("readActionsPayload", () => { + it("rejects conflicting inline and file actions before reading the file", async () => { + await expect( + readActionsPayload({ actions: "[]", actionsFile: "/tmp/openclaw-browser-actions.json" }), + ).rejects.toThrow("Specify only one of --actions or --actions-file"); + }); }); diff --git a/extensions/browser/src/cli/browser-cli-actions-input/shared.ts b/extensions/browser/src/cli/browser-cli-actions-input/shared.ts index 2246401407bc..8daf22749de1 100644 --- a/extensions/browser/src/cli/browser-cli-actions-input/shared.ts +++ b/extensions/browser/src/cli/browser-cli-actions-input/shared.ts @@ -93,6 +93,9 @@ export async function readFields(opts: { fields?: string; fieldsFile?: string; }): Promise { + if (opts.fields !== undefined && opts.fieldsFile !== undefined) { + throw new Error("Specify only one of --fields or --fields-file"); + } const payload = opts.fieldsFile ? await readFile(opts.fieldsFile) : (opts.fields ?? ""); if (!payload.trim()) { throw new Error("fields are required"); @@ -152,6 +155,9 @@ export async function readActionsPayload(opts: { actions?: string; actionsFile?: string; }): Promise { + if (opts.actions !== undefined && opts.actionsFile !== undefined) { + throw new Error("Specify only one of --actions or --actions-file"); + } if (opts.actionsFile) { return opts.actionsFile === "-" ? await readStdinText() : await readFile(opts.actionsFile); } diff --git a/extensions/browser/src/cli/browser-cli-inspect.test.ts b/extensions/browser/src/cli/browser-cli-inspect.test.ts index 93cda4bd6188..fe820c7ca287 100644 --- a/extensions/browser/src/cli/browser-cli-inspect.test.ts +++ b/extensions/browser/src/cli/browser-cli-inspect.test.ts @@ -186,6 +186,26 @@ describe("browser cli snapshot defaults", () => { expect(params?.query?.depth).toBe(0); }); + it.each([ + { + args: ["screenshot", "tab-1", "--type", "webp"], + error: "Invalid --type: expected png or jpeg", + }, + { + args: ["snapshot", "--format", "html"], + error: "Invalid --format: expected aria or ai", + }, + { + args: ["snapshot", "--mode", "full"], + error: "Invalid --mode: expected efficient", + }, + ])("rejects unsupported inspect option values before dispatch", async ({ args, error }) => { + await expect(runBrowserInspect(args)).rejects.toThrow("__exit__:1"); + + expect(runtime.error.mock.calls.at(-1)?.[0]).toContain(error); + expect(sharedMocks.callBrowserRequest).not.toHaveBeenCalled(); + }); + it("sends screenshot request with trimmed target id and jpeg type", async () => { const params = await runBrowserInspect(["screenshot", " tab-1 ", "--type", "jpeg"], true); expect(params?.path).toBe("/screenshot"); diff --git a/extensions/browser/src/cli/browser-cli-inspect.ts b/extensions/browser/src/cli/browser-cli-inspect.ts index c940439cd097..2d7a5f0e5eba 100644 --- a/extensions/browser/src/cli/browser-cli-inspect.ts +++ b/extensions/browser/src/cli/browser-cli-inspect.ts @@ -39,6 +39,19 @@ function parseOptionalIntegerOption( return parsed; } +function parseBrowserChoiceOption( + value: string, + label: string, + choices: readonly T[], +): T | undefined { + if ((choices as readonly string[]).includes(value)) { + return value as T; + } + defaultRuntime.error(danger(`Invalid ${label}: expected ${choices.join(" or ")}`)); + defaultRuntime.exit(1); + return undefined; +} + /** Registers Browser screenshot and snapshot commands. */ export function registerBrowserInspectCommands( browser: Command, @@ -60,6 +73,10 @@ export function registerBrowserInspectCommands( .action(async (targetId: string | undefined, opts, cmd) => { const parent = parentOpts(cmd); const profile = parent?.browserProfile; + const type = parseBrowserChoiceOption(opts.type, "--type", ["png", "jpeg"]); + if (type === undefined) { + return; + } try { const result = await callBrowserRequest<{ path: string }>( parent, @@ -73,7 +90,7 @@ export function registerBrowserInspectCommands( ref: normalizeOptionalString(opts.ref), element: normalizeOptionalString(opts.element), labels: Boolean(opts.labels), - type: opts.type === "jpeg" ? "jpeg" : "png", + type, }, }, { timeoutMs: 20000 }, @@ -108,7 +125,17 @@ export function registerBrowserInspectCommands( .action(async (opts, cmd: Command) => { const parent = parentOpts(cmd); const profile = parent?.browserProfile; - const format = opts.format === "aria" ? "aria" : "ai"; + const format = parseBrowserChoiceOption(opts.format, "--format", ["aria", "ai"]); + if (format === undefined) { + return; + } + const explicitMode = + opts.mode === undefined + ? undefined + : parseBrowserChoiceOption(opts.mode, "--mode", ["efficient"]); + if (opts.mode !== undefined && explicitMode === undefined) { + return; + } const formatWasExplicit = cmd.getOptionValueSource("format") === "cli"; const configMode = !formatWasExplicit && @@ -116,7 +143,8 @@ export function registerBrowserInspectCommands( getRuntimeConfig().browser?.snapshotDefaults?.mode === "efficient" ? "efficient" : undefined; - const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode; + const mode = + opts.efficient === true || explicitMode === "efficient" ? "efficient" : configMode; const limit = parseOptionalIntegerOption(opts.limit, "--limit", { min: 1 }); const depth = parseOptionalIntegerOption(opts.depth, "--depth", { min: 0 }); if ( diff --git a/extensions/browser/src/cli/browser-cli-manage.test.ts b/extensions/browser/src/cli/browser-cli-manage.test.ts index c3867f5d9438..fd1d7682b260 100644 --- a/extensions/browser/src/cli/browser-cli-manage.test.ts +++ b/extensions/browser/src/cli/browser-cli-manage.test.ts @@ -475,6 +475,21 @@ describe("browser manage output", () => { ); }); + it("rejects unsupported profile drivers before creating a profile", async () => { + const program = createBrowserManageProgram(); + + await expect( + program.parseAsync(["browser", "create-profile", "--name", "test", "--driver", "chromium"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + expect(getBrowserCliRuntimeCapture().runtimeErrors.at(-1)).toContain( + "--driver must be openclaw or existing-session", + ); + expect(getBrowserManageCallBrowserRequestMock()).not.toHaveBeenCalled(); + }); + it("prints a readable browser doctor report", async () => { getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => { if (req.path === "/") { diff --git a/extensions/browser/src/cli/browser-cli-manage.ts b/extensions/browser/src/cli/browser-cli-manage.ts index 1ba6fd525eeb..a383349cf546 100644 --- a/extensions/browser/src/cli/browser-cli-manage.ts +++ b/extensions/browser/src/cli/browser-cli-manage.ts @@ -837,6 +837,13 @@ export function registerBrowserManageCommands( ) => { const parent = parentOpts(cmd); await runBrowserCommand(async () => { + if ( + opts.driver !== undefined && + opts.driver !== "openclaw" && + opts.driver !== "existing-session" + ) { + throw new Error("--driver must be openclaw or existing-session"); + } const result = await callBrowserRequest( parent, { diff --git a/extensions/memory-lancedb/memory-cli.test.ts b/extensions/memory-lancedb/memory-cli.test.ts index 4c5619dfb51c..87055caa03a1 100644 --- a/extensions/memory-lancedb/memory-cli.test.ts +++ b/extensions/memory-lancedb/memory-cli.test.ts @@ -69,6 +69,26 @@ describe("memory-lancedb CLI embedding lifecycle", () => { expect(harness.close).toHaveBeenCalledTimes(1); }); + it("rejects an invalid limit before generating an embedding", async () => { + const harness = createHarness(); + + await expect( + harness.program.parseAsync([ + "node", + "openclaw", + "ltm", + "search", + "hello", + "--limit", + "5items", + ]), + ).rejects.toThrow("--limit must be a positive integer"); + + expect(harness.embed).not.toHaveBeenCalled(); + expect(harness.search).not.toHaveBeenCalled(); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + it("preserves a falsy search rejection over cleanup failure", async () => { const harness = createHarness({ embedError: null, diff --git a/extensions/memory-lancedb/memory-cli.ts b/extensions/memory-lancedb/memory-cli.ts index 4113b6159889..9e60d1f96760 100644 --- a/extensions/memory-lancedb/memory-cli.ts +++ b/extensions/memory-lancedb/memory-cli.ts @@ -140,8 +140,8 @@ export function registerMemoryCli( let operationFailed = false; try { const agentId = resolveCliAgentId(opts.agent); - const vector = await embeddings.embed(normalizeRecallQuery(query, recallMaxChars)); const limit = parsePositiveIntegerOption(opts.limit, "--limit"); + const vector = await embeddings.embed(normalizeRecallQuery(query, recallMaxChars)); const results = await db.search(agentId, vector, limit, 0.3); const output = results.map((r) => ({ id: r.entry.id, diff --git a/src/cli/channels-cli.test.ts b/src/cli/channels-cli.test.ts index e7830fcbe13b..96f233c16117 100644 --- a/src/cli/channels-cli.test.ts +++ b/src/cli/channels-cli.test.ts @@ -17,6 +17,7 @@ const listRawChannelPluginCatalogEntriesMock = vi.hoisted(() => vi.fn<() => ChannelPluginCatalogEntry[]>(() => []), ); const channelsAddCommandMock = vi.hoisted(() => vi.fn(async () => undefined)); +const channelsResolveCommandMock = vi.hoisted(() => vi.fn(async () => undefined)); const runtimeMock = vi.hoisted(() => ({ log: vi.fn(), error: vi.fn(), @@ -33,6 +34,7 @@ vi.mock("../channels/plugins/catalog.js", () => ({ vi.mock("../commands/channels.js", () => ({ channelsAddCommand: channelsAddCommandMock, + channelsResolveCommand: channelsResolveCommandMock, })); vi.mock("../runtime.js", () => ({ @@ -89,6 +91,38 @@ describe("registerChannelsCli", () => { expect(getChannelSubcommandNames(program, "dead-letters")).toEqual(["list", "resubmit"]); }); + it.each(["auto", "user", "group", "channel"])( + "forwards the supported %s resolve target kind", + async (kind) => { + const program = new Command().name("openclaw").exitOverride(); + const args = ["channels", "resolve", "--kind", kind, "room"]; + + await registerChannelsCli(program, ["node", "openclaw", ...args]); + await program.parseAsync(args, { from: "user" }); + + expect(channelsResolveCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ kind, entries: ["room"] }), + runtimeMock, + ); + }, + ); + + it("rejects unsupported resolve target kinds before dispatching", async () => { + const writeErr = vi.fn(); + const program = new Command().name("openclaw").exitOverride().configureOutput({ writeErr }); + const args = ["channels", "resolve", "--kind", "person", "room"]; + + await registerChannelsCli(program, ["node", "openclaw", ...args]); + + await expect(program.parseAsync(args, { from: "user" })).rejects.toMatchObject({ + code: "commander.invalidArgument", + }); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining("Allowed choices are auto, user, group, channel."), + ); + expect(channelsResolveCommandMock).not.toHaveBeenCalled(); + }); + it("registers ClickClack setup options before an external channel plugin is installed", async () => { listBundledPackageChannelMetadataMock.mockReturnValueOnce([ { diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 3181e59760b5..347745ae04d3 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -1,5 +1,5 @@ // Commander registration for channel discovery, setup, status, auth, and diagnostics commands. -import type { Command } from "commander"; +import { Option, type Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { danger } from "../globals.js"; @@ -221,7 +221,11 @@ export async function registerChannelsCli( .argument("", "Entries to resolve (names or ids)") .option("--channel ", `Channel (${channelNames})`) .option("--account ", "Account id (accountId)") - .option("--kind ", "Target kind (auto|user|group)", "auto") + .addOption( + new Option("--kind ", "Target kind (auto|user|group|channel)") + .choices(["auto", "user", "group", "channel"]) + .default("auto"), + ) .option("--json", "Output JSON", false) .action(async (entries, opts) => { await runChannelsCommand(async () => { @@ -230,7 +234,7 @@ export async function registerChannelsCli( { channel: opts.channel as string | undefined, account: opts.account as string | undefined, - kind: opts.kind as "auto" | "user" | "group", + kind: opts.kind as "auto" | "user" | "group" | "channel", json: Boolean(opts.json), entries: Array.isArray(entries) ? entries : [String(entries)], }, diff --git a/src/cli/command-options.test.ts b/src/cli/command-options.test.ts index 43e83fbce85b..823fb5acc52c 100644 --- a/src/cli/command-options.test.ts +++ b/src/cli/command-options.test.ts @@ -1,7 +1,7 @@ // Command option tests cover shared CLI option registration and parsing. import { Command } from "commander"; import { describe, expect, it } from "vitest"; -import { inheritOptionFromParent } from "./command-options.js"; +import { hasExplicitOptions, inheritOptionFromParent } from "./command-options.js"; function attachRunCommandAndCaptureInheritedToken(command: Command) { let inherited: string | undefined; @@ -14,6 +14,43 @@ function attachRunCommandAndCaptureInheritedToken(command: Command) { return () => inherited; } +describe("hasExplicitOptions", () => { + it.each([ + { source: "cli", expected: true }, + { source: "config", expected: false }, + { source: "env", expected: false }, + { source: "implied", expected: false }, + { source: "default", expected: false }, + { source: undefined, expected: false }, + ] as const)("recognizes only cli option sources ($source)", ({ source, expected }) => { + const command = new Command().option("--token ", "Token"); + command.setOptionValueWithSource("token", "test-token", source); + + expect(hasExplicitOptions(command, ["token"])).toBe(expected); + }); + + it("recognizes an explicitly negated option", async () => { + const command = new Command().option("--no-color", "Disable color"); + + await command.parseAsync(["--no-color"], { from: "user" }); + + expect(command.getOptionValue("color")).toBe(false); + expect(hasExplicitOptions(command, ["color"])).toBe(true); + }); + + it("checks every requested option", async () => { + const command = new Command() + .option("--token ", "Token") + .option("--force", "Force", false); + + await command.parseAsync(["--force"], { from: "user" }); + + expect(hasExplicitOptions(command, ["token", "force"])).toBe(true); + expect(hasExplicitOptions(command, ["token"])).toBe(false); + expect(hasExplicitOptions(command, [])).toBe(false); + }); +}); + describe("inheritOptionFromParent", () => { it.each([ { @@ -51,6 +88,35 @@ describe("inheritOptionFromParent", () => { expect(inheritOptionFromParent(run, "token")).toBeUndefined(); }); + it("inherits explicitly negated ancestor values", async () => { + const program = new Command(); + const gateway = program.command("gateway").option("--no-color", "Disable color"); + const run = gateway + .command("run") + .option("--no-color", "Disable color") + .action(() => {}); + + await program.parseAsync(["gateway", "--no-color", "run"], { from: "user" }); + + expect(inheritOptionFromParent(run, "color")).toBe(false); + }); + + it("does not override an explicitly negated child value", async () => { + const program = new Command(); + const gateway = program.command("gateway").option("--force", "Force"); + const run = gateway + .command("run") + .option("--no-force", "Disable force") + .action(() => {}); + + await program.parseAsync(["gateway", "--force", "run", "--no-force"], { + from: "user", + }); + + expect(run.getOptionValue("force")).toBe(false); + expect(inheritOptionFromParent(run, "force")).toBeUndefined(); + }); + it("does not inherit from ancestors beyond the bounded traversal depth", async () => { const program = new Command().option("--token ", "Root token"); const level1 = program.command("level1"); diff --git a/src/cli/completion-cli.aliases.test.ts b/src/cli/completion-cli.aliases.test.ts new file mode 100644 index 000000000000..6763ac2b1f6a --- /dev/null +++ b/src/cli/completion-cli.aliases.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { getCompletionScript } from "./completion-cli.js"; +import { + createAliasedCompletionProgram, + itWithFish, + itWithPowerShell, + runGeneratedBashCompletion, + runGeneratedFishCompletion, + runGeneratedPowerShellCompletion, +} from "./completion-cli.test-support.js"; + +// Aliases are typeable commands, so every shell must preserve their nested command paths. +describe("completion-cli command aliases", () => { + itWithFish.each([ + ["a canonical root command", "openclaw --profile work inf", "infer"], + ["an aliased root command", "openclaw --profile work cap", "capability"], + ["an inline profile and alias", "openclaw --profile=work cap", "capability"], + ["an alias-shaped profile value", "openclaw --profile capability cap", "capability"], + ["a repeated profile and alias", "openclaw --profile first --profile second cap", "capability"], + ])("completes real Fish root aliases after %s", (_name, commandLine, expected) => { + expect(runGeneratedFishCompletion(createAliasedCompletionProgram(), commandLine)).toContain( + expected, + ); + }); + + it("completes root and nested aliases in zsh lists and dispatch", () => { + const script = getCompletionScript("zsh", createAliasedCompletionProgram()); + + expect(script).toContain("'capability[Run inference]'"); + expect(script).toContain("(infer|capability) _openclaw_infer ;;"); + expect(script).toContain("'create[Add a job]'"); + expect(script).toContain("(add|create) _openclaw_cron_add ;;"); + }); + + it("completes root and nested aliases in bash command paths", () => { + const script = getCompletionScript("bash", createAliasedCompletionProgram()); + + expect(script).toContain('opts="infer capability cron --profile"'); + expect(script).toContain('"infer"|"capability")'); + expect(script).toContain('"cron")'); + expect(script).toContain('opts="add create"'); + expect(script).toContain('"cron add"|"cron create")'); + expect(script).toContain('opts="--at"'); + }); + + it.skipIf(process.platform === "win32")("offers options after a nested alias in bash", () => { + expect( + runGeneratedBashCompletion(createAliasedCompletionProgram(), [ + "openclaw", + "--profile", + "work", + "cron", + "create", + "--a", + ]), + ).toEqual(["--at"]); + }); + + it("completes aliases and their subtrees in fish", () => { + const script = getCompletionScript("fish", createAliasedCompletionProgram()); + + expect(script).toContain( + 'complete -c openclaw -n "__openclaw_command_path_matches -- --profile" -a "capability" -d \'Run inference\'', + ); + expect(script).toContain( + 'complete -c openclaw -n "__openclaw_command_path_matches capability -- --profile" -a "embed" -d \'Embed text\'', + ); + expect(script).toContain( + 'complete -c openclaw -n "__openclaw_command_path_matches cron -- --profile" -a "create" -d \'Add a job\'', + ); + expect(script).toContain( + "complete -c openclaw -n \"__openclaw_command_path_matches cron create -- --profile --at\" -l at -r -d 'Schedule time'", + ); + }); + + itWithFish.each([ + ["an aliased nested command", "openclaw cron create -"], + ["a canonical nested command", "openclaw cron add -"], + ["a global profile", "openclaw --profile work cron create -"], + ["an inline global profile", "openclaw --profile=work cron create -"], + ["repeated global profiles", "openclaw --profile first --profile second cron create -"], + ["an inherited global profile", "openclaw cron --profile work create -"], + ["a parent long option", "openclaw cron --timezone UTC create -"], + ["a parent short option", "openclaw cron -z UTC create -"], + ["an inline parent option", "openclaw cron --timezone=UTC create -"], + ["a parent boolean option", "openclaw cron --verbose create -"], + ])("keeps real Fish alias completions scoped after %s", (_name, commandLine) => { + const program = createAliasedCompletionProgram(); + const cron = program.commands.find((command) => command.name() === "cron"); + if (!cron) { + throw new Error("Cron command is unavailable"); + } + cron.option("-z, --timezone ", "Time zone").option("--verbose", "Verbose output"); + + expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--at"]); + }); + + itWithFish.each([ + ["an aliased positional argument", "openclaw cron create meeting -"], + ["a canonical positional argument", "openclaw cron add meeting -"], + ["a profiled positional argument", "openclaw --profile work cron create meeting -"], + ["a parent option and positional argument", "openclaw cron -z UTC create meeting -"], + ])("keeps real Fish alias options after %s", (_name, commandLine) => { + const program = createAliasedCompletionProgram(); + const cron = program.commands.find((command) => command.name() === "cron"); + const add = cron?.commands.find((command) => command.name() === "add"); + if (!cron || !add) { + throw new Error("Cron add command is unavailable"); + } + cron.option("-z, --timezone ", "Time zone"); + add.argument("[label...]", "Job label"); + + expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--at"]); + }); + + it("completes aliases and alias command paths in PowerShell", () => { + const script = getCompletionScript("powershell", createAliasedCompletionProgram()); + + expect(script).toContain("$completions = @('infer','capability','cron','--profile')"); + expect(script).toContain("if ($commandPath -eq 'capability') {"); + expect(script).toContain("if ($commandPath -eq 'cron create') {"); + }); + + it("tracks PowerShell command paths past inherited value-taking flags", () => { + const script = getCompletionScript("powershell", createAliasedCompletionProgram()); + + expect(script).toContain("$valueOptions = @('--profile')"); + expect(script).toContain("switch ($candidatePath)"); + expect(script).toContain("'cron create'"); + expect(script).toContain("'--profile','--at'"); + }); + + itWithPowerShell.each([ + ["a global option", "openclaw --profile work cron create --a"], + ["an inline global option", "openclaw --profile=work cron create --a"], + ["repeated global options", "openclaw --profile first --profile second cron create --a"], + ["an inherited option after the parent", "openclaw cron --profile work create --a"], + ["the canonical nested command", "openclaw --profile work cron add --a"], + ])("completes real PowerShell nested aliases after %s", (_name, commandLine) => { + expect(runGeneratedPowerShellCompletion(createAliasedCompletionProgram(), commandLine)).toEqual( + ["--at"], + ); + }); +}); diff --git a/src/cli/completion-cli.test-support.ts b/src/cli/completion-cli.test-support.ts new file mode 100644 index 000000000000..3b237ef113e7 --- /dev/null +++ b/src/cli/completion-cli.test-support.ts @@ -0,0 +1,133 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { Command } from "commander"; +import { expect, it } from "vitest"; +import { getCompletionScript } from "./completion-cli.js"; +import { quoteCliArg } from "./quote-cli-arg.js"; + +export function createAliasedCompletionProgram(): Command { + const program = new Command(); + program.name("openclaw"); + program.option("--profile ", "Profile"); + const infer = program.command("infer").alias("capability").description("Run inference"); + infer.command("embed").description("Embed text").option("--model ", "Model id"); + const cron = program.command("cron").description("Cron commands"); + cron + .command("add") + .alias("create") + .description("Add a job") + .option("--at