diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts index 61a728d1f7c3..022b95175302 100644 --- a/src/cli/completion-cli.test.ts +++ b/src/cli/completion-cli.test.ts @@ -67,6 +67,42 @@ printf '%s\\n' "\${COMPREPLY[@]}" return result.stdout.split("\n").filter(Boolean); } +function findFish(): string | null { + const executable = process.platform === "win32" ? "fish.exe" : "fish"; + const candidates = (process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .map((directory) => path.join(directory, executable)); + return candidates.find((candidate) => existsSync(candidate)) ?? null; +} + +const fishPath = findFish(); +const itWithFish = fishPath ? it : it.skip; + +function runGeneratedFishCompletion(program: Command, commandLine: string): string[] { + if (!fishPath) { + throw new Error("Fish is unavailable"); + } + + const script = getCompletionScript("fish", program); + const quotedCommandLine = commandLine.replaceAll("'", "\\'"); + const result = spawnSync( + fishPath, + ["--no-config", "--command", `${script}\ncomplete --do-complete '${quotedCommandLine}'`], + { encoding: "utf8", timeout: 15_000 }, + ); + + if (result.error) { + throw result.error; + } + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + return result.stdout + .split(/\r?\n/) + .filter(Boolean) + .map((completion) => completion.split("\t")[0] ?? completion); +} + function findPowerShell(): string | null { const executable = process.platform === "win32" ? "pwsh.exe" : "pwsh"; const candidates = [ @@ -283,6 +319,50 @@ describe("completion-cli", () => { expect(script).toContain("if contains -- $flag $value_options"); }); + it("distinguishes Fish child command paths from positional arguments", () => { + const script = getCompletionScript("fish", createCompletionProgram()); + + expect(script).toContain('switch "$candidate_path"'); + expect(script).toContain("'gateway status'"); + }); + + itWithFish.each([ + ["the exact nested command", "openclaw gateway status -"], + ["a separate long option value", "openclaw gateway --token secret status -"], + ["a separate short option value", "openclaw gateway -t secret status -"], + ["an inline long option value", "openclaw gateway --token=secret status -"], + ["an inline short option value", "openclaw gateway -t=secret status -"], + ["a parent boolean option", "openclaw gateway --force status -"], + ])("keeps real Fish completions scoped after %s", (_name, commandLine) => { + expect(runGeneratedFishCompletion(createCompletionProgram(), commandLine)).toEqual(["--json"]); + }); + + itWithFish.each([ + ["a positional argument", "openclaw gateway status query -"], + ["multiple positional arguments", "openclaw gateway status first second -"], + ["a positional argument named like a sibling", "openclaw gateway status restart -"], + ["a long option and positional argument", "openclaw gateway --token secret status query -"], + ["an inline option and positional argument", "openclaw gateway --token=secret status query -"], + ])("keeps real Fish leaf options after %s", (_name, commandLine) => { + const program = createCompletionProgram(); + const gateway = program.commands.find((command) => command.name() === "gateway"); + const status = gateway?.commands.find((command) => command.name() === "status"); + if (!status) { + throw new Error("Gateway status command is unavailable"); + } + status.argument("[query...]", "Search query"); + + expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--json"]); + }); + + itWithFish("preserves documented short and long completion flags in real Fish", () => { + expect( + runGeneratedFishCompletion(createDocumentedCompletionProgram(), "openclaw completion -"), + ).toEqual( + expect.arrayContaining(["-s", "--shell", "-i", "--install", "-y", "--yes", "--write-state"]), + ); + }); + it("scopes fish value-taking option skips to the active command path", () => { const script = getCompletionScript("fish", createCompletionProgram()); @@ -501,6 +581,46 @@ printf '%s\\n' "\${COMPREPLY[@]}" ); }); + 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()); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 7163641cc4ff..3e6b735e804d 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -93,7 +93,22 @@ function collectFishPathOptionFlags( return [...flags]; } -function generateFishPathHelper(rootCmd: string): string { +function generateFishPathHelper(rootCmd: string, program: Command): string { + const knownCommandPaths = collectBashCompletionContexts(program, []) + .flatMap((context) => context.pathVariants) + .map((pathSegments) => `'${pathSegments.join(" ").replaceAll("'", "'\\''")}'`) + .join(" "); + const rejectDescendantCommands = knownCommandPaths + ? ` + if test (count $command_tokens) -gt (count $expected) + set -l next_index (math (count $expected) + 1) + set -l candidate_path (string join " " $expected $command_tokens[$next_index]) + switch "$candidate_path" + case ${knownCommandPaths} + return 1 + end + end` + : ""; // Fish needs a helper to ignore option values while matching nested command paths. return ` function __${rootCmd}_command_path_matches @@ -137,6 +152,7 @@ function __${rootCmd}_command_path_matches return 1 end end +${rejectDescendantCommands} return 0 end `; @@ -604,7 +620,7 @@ ${commandPathCases} function generateFishCompletion(program: Command): string { const rootCmd = program.name(); - const segments: string[] = [generateFishPathHelper(rootCmd)]; + const segments: string[] = [generateFishPathHelper(rootCmd, program)]; const visit = (cmd: Command, parentVariants: string[][]) => { // One condition per alias-expanded parent path so completion keeps working