diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts index 5d020b857f7c..0930dc406b4d 100644 --- a/src/cli/completion-cli.test.ts +++ b/src/cli/completion-cli.test.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { describe, expect, it } from "vitest"; import { getCompletionScript, registerCompletionCli } from "./completion-cli.js"; @@ -400,6 +400,18 @@ describe("completion-cli", () => { ); }); + itWithFish.each([ + ["a separated long optional value", "openclaw --color a", "always"], + ["a separated short optional value", "openclaw -c n", "never"], + ["an attached long optional value", "openclaw --color=a", "--color=always"], + ])("completes real Fish Commander choices after %s", (_name, commandLine, expected) => { + const program = new Command() + .name("openclaw") + .addOption(new Option("-c, --color [when]").choices(["always", "never"])); + + expect(runGeneratedFishCompletion(program, commandLine)).toContain(expected); + }); + it("scopes fish value-taking option skips to the active command path", () => { const script = getCompletionScript("fish", createCompletionProgram()); @@ -420,10 +432,10 @@ describe("completion-cli", () => { const fishScript = getCompletionScript("fish", program); expect(fishScript).toContain( - "complete -c openclaw -n \"__openclaw_command_path_matches -- --trigger-script --ws --workspace\" -l trigger-script -d 'Condition script file, or - for stdin'", + "complete -c openclaw -n \"__openclaw_command_path_matches -- --trigger-script --ws --workspace\" -l trigger-script -r -d 'Condition script file, or - for stdin'", ); expect(fishScript).not.toContain(" -s > "); - expect(fishScript).toContain(" -l ws -l workspace -d 'Workspace'"); + expect(fishScript).toContain(" -l ws -l workspace -r -d 'Workspace'"); expect(getCompletionScript("bash", program)).not.toContain("--trigger-script ->"); expect(getCompletionScript("zsh", program)).not.toContain("{--trigger-script,->}"); }); @@ -511,6 +523,177 @@ describe("completion-cli", () => { expect(zshScript).toContain("{--yes,-y}"); }); + it("preserves required shell values and their Commander choices in Fish and Zsh", () => { + const program = createDocumentedCompletionProgram(); + const fishScript = getCompletionScript("fish", program); + const zshScript = getCompletionScript("zsh", program); + + expect(fishScript).toContain(" -s s -l shell -r -f -a "); + expect(fishScript).toContain(`"'zsh' 'bash' 'powershell' 'fish'"`); + expect(zshScript).toContain( + `{--shell,-s}"[Shell to generate completion for (default: zsh)]:shell:('zsh' 'bash' 'powershell' 'fish')"`, + ); + expect(zshScript).toContain('{--token,-t}"[Gateway token]:token:"'); + }); + + it.skipIf(process.platform === "win32")( + "completes Commander option choices instead of commands in real Bash", + () => { + const program = createDocumentedCompletionProgram(); + + expect( + runGeneratedBashCompletion(program, ["openclaw", "completion", "--shell", ""]), + ).toEqual(["zsh", "bash", "powershell", "fish"]); + expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-s", "f"])).toEqual([ + "fish", + ]); + expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "--shell=f"])).toEqual([ + "--shell=fish", + ]); + expect( + runGeneratedBashCompletion(program, ["openclaw", "completion", "--shell", "=", "f"]), + ).toEqual(["fish"]); + expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-sf"])).toEqual([ + "-sfish", + ]); + expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-ysf"])).toEqual([ + "-ysfish", + ]); + expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-ys", "f"])).toEqual([ + "fish", + ]); + expect(runGeneratedBashCompletion(program, ["openclaw", "completion", "-ys"])).toEqual([ + "-yszsh", + "-ysbash", + "-yspowershell", + "-ysfish", + ]); + }, + ); + + it.skipIf(process.platform === "win32")( + "keeps optional choice values from consuming the following option in real Bash", + () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("-c, --color [when]").choices(["always", "never"])) + .option("-v, --verbose", "Verbose output"); + + expect(runGeneratedBashCompletion(program, ["openclaw", "--color", "a"])).toEqual(["always"]); + expect(runGeneratedBashCompletion(program, ["openclaw", "--color", "--v"])).toEqual([ + "--verbose", + ]); + expect(runGeneratedBashCompletion(program, ["openclaw", "-ca"])).toEqual(["-calways"]); + expect(runGeneratedBashCompletion(program, ["openclaw", "-vca"])).toEqual(["-vcalways"]); + }, + ); + + it("includes Commander option choices in the PowerShell argument completer", () => { + const script = getCompletionScript("powershell", createDocumentedCompletionProgram()); + + expect(script).toContain("switch ($candidatePath)"); + expect(script).toContain("$choiceFlag -in @('-s','--shell')"); + expect(script).toContain("$wordToComplete -match '^(--[^=]+)=(.*)$'"); + expect(script).toContain("$wordToComplete -match '^-[^-].+$'"); + expect(script).toContain("[WildcardPattern]::Escape($choicePrefix)"); + expect(script).toContain("@('zsh','bash','powershell','fish')"); + }); + + it("omits empty PowerShell command-path switches for root-only programs", () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("--theme ").choices(["light", "dark"])); + + expect(getCompletionScript("powershell", program)).not.toContain("switch ($candidatePath)"); + }); + + it("quotes PowerShell choice completion text while preserving its display value", () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("--theme ").choices(["light blue", "Bob's green", "path`name"])); + + const script = getCompletionScript("powershell", program); + + expect(script).toContain('$completionText = "$choiceCompletionPrefix$_"'); + expect(script).toContain('$completionText.Replace("\'", "\'\'")'); + expect(script).toContain( + "[System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_)", + ); + }); + + itWithPowerShell.each([ + ["a separate option value", "openclaw completion --shell f", "fish"], + ["an attached option value", "openclaw completion --shell=f", "--shell=fish"], + ["an attached short option value", "openclaw completion -sf", "-sfish"], + ["a short-option cluster value", "openclaw completion -ysf", "-ysfish"], + ["a separated short-option cluster value", "openclaw completion -ys f", "fish"], + ])("completes PowerShell Commander choices after %s", (_name, commandLine, expected) => { + expect( + runGeneratedPowerShellCompletion(createDocumentedCompletionProgram(), commandLine), + ).toEqual([expected]); + }); + + itWithPowerShell("completes an empty attached short-option cluster value", () => { + expect( + runGeneratedPowerShellCompletion( + createDocumentedCompletionProgram(), + "openclaw completion -ys", + ), + ).toEqual(["-yszsh", "-ysbash", "-yspowershell", "-ysfish"]); + }); + + itWithPowerShell.each([ + ["a spaced choice", "openclaw --theme l", "'light blue'"], + ["an attached spaced choice", "openclaw --theme=l", "'--theme=light blue'"], + ["an apostrophe", "openclaw --theme Bob", "'Bob''s green'"], + ["a backtick", "openclaw --theme p", "'path`name'"], + ])("quotes %s in real PowerShell completion text", (_name, commandLine, expected) => { + const program = new Command() + .name("openclaw") + .addOption(new Option("--theme ").choices(["light blue", "Bob's green", "path`name"])); + + expect(runGeneratedPowerShellCompletion(program, commandLine)).toEqual([expected]); + }); + + itWithPowerShell( + "keeps optional choice values from consuming the following PowerShell option", + () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("-c, --color [when]").choices(["always", "never"])) + .option("-v, --verbose", "Verbose output"); + + expect(runGeneratedPowerShellCompletion(program, "openclaw --color a")).toEqual(["always"]); + expect(runGeneratedPowerShellCompletion(program, "openclaw --color --v")).toEqual([ + "--verbose", + ]); + }, + ); + + it.skipIf(process.platform === "win32")( + "preserves spaces and apostrophes inside individual Commander choices", + () => { + const program = new Command() + .name("openclaw") + .addOption( + new Option("--theme ", "Color theme").choices([ + "light blue", + "dark", + "Bob's green", + ]), + ); + + expect(runGeneratedBashCompletion(program, ["openclaw", "--theme", "l"])).toEqual([ + "light blue", + ]); + expect(runGeneratedBashCompletion(program, ["openclaw", "--theme", "Bob"])).toEqual([ + "Bob's green", + ]); + expect(getCompletionScript("fish", program)).toContain(`"'light blue' 'dark'`); + expect(getCompletionScript("zsh", program)).toContain("('light blue' 'dark' 'Bob"); + }, + ); + it("generates valid Bash completion without subcommands", () => { if (process.platform === "win32") { return; @@ -626,7 +809,7 @@ printf '%s\\n' "\${COMPREPLY[@]}" '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 -d 'Schedule time'", + "complete -c openclaw -n \"__openclaw_command_path_matches cron create -- --profile --at\" -l at -r -d 'Schedule time'", ); }); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 5a1ffe245d84..6c7da7109be6 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -304,10 +304,17 @@ function generateZshArgs(cmd: Command): string { const name = preferredCompletionFlag(opt); const alternate = flags.find((flag) => flag !== name); const desc = escapeZshDoubleQuotedDescription(opt.description); + const choices = opt.argChoices + ?.map((choice) => escapeZshDoubleQuotedDescription(`'${choice.replaceAll("'", "'\\''")}'`)) + .join(" "); + const argument = + opt.required || opt.optional + ? `${opt.optional ? "::" : ":"}${opt.attributeName()}:${choices ? `(${choices})` : ""}` + : ""; if (alternate) { - return `"(${name} ${alternate})"{${name},${alternate}}"[${desc}]"`; + return `"(${name} ${alternate})"{${name},${alternate}}"[${desc}]${argument}"`; } - return `"${name}[${desc}]"`; + return `"${name}[${desc}]${argument}"`; }) .join(" \\\n "); } @@ -391,9 +398,11 @@ function generateBashCompletion(program: Command): string { const rootCompletions = root.completions; const rootValueOptions = root.valueOptions; const commandPathUpdate = generateBashCommandPathUpdate(contexts); + const choiceCompletion = generateBashOptionChoiceCompletion([root, ...contexts]); return ` _${rootCmd}_completion() { local cur opts command_path candidate_path value_options word flag i + local choice_flag choice_prefix choice_completion_prefix short_group short_flag short_index COMPREPLY=() cur="\${COMP_WORDS[COMP_CWORD]}" opts="${rootCompletions.join(" ")}" @@ -419,6 +428,42 @@ _${rootCmd}_completion() { ${commandPathUpdate} done + choice_flag="\${COMP_WORDS[COMP_CWORD-1]}" + choice_prefix="\${cur}" + choice_completion_prefix="" + if [[ "\${cur}" == --*=* ]]; then + choice_flag="\${cur%%=*}" + choice_prefix="\${cur#*=}" + choice_completion_prefix="\${choice_flag}=" + elif [[ "\${choice_flag}" == "=" ]]; then + choice_flag="\${COMP_WORDS[COMP_CWORD-2]}" + fi + if [[ "\${choice_flag}" == -??* && "\${choice_flag}" != --* ]]; then + short_group="\${choice_flag#-}" + for ((short_index = 0; short_index < \${#short_group}; short_index++)); do + short_flag="-\${short_group:short_index:1}" + if [[ " \${value_options} " == *" \${short_flag} "* ]]; then + if ((short_index == \${#short_group} - 1)); then + choice_flag="\${short_flag}" + fi + break + fi + done + fi + if [[ "\${cur}" == -??* && "\${cur}" != --* ]]; then + short_group="\${cur#-}" + for ((short_index = 0; short_index < \${#short_group}; short_index++)); do + short_flag="-\${short_group:short_index:1}" + if [[ " \${value_options} " == *" \${short_flag} "* ]]; then + choice_flag="\${short_flag}" + choice_prefix="\${short_group:short_index+1}" + choice_completion_prefix="-\${short_group:0:short_index+1}" + break + fi + done + fi + +${choiceCompletion} COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) } @@ -426,6 +471,44 @@ complete -F _${rootCmd}_completion ${rootCmd} `; } +function generateBashOptionChoiceCompletion(contexts: ShellCompletionContext[]): string { + const cases = contexts + .filter(({ valueChoices }) => valueChoices.length > 0) + .map(({ pathVariants, valueChoices }) => { + const commandPaths = pathVariants.map((segments) => `"${segments.join(" ")}"`).join("|"); + const optionCases = valueChoices + .map(({ flags, choices, requiresValue }) => { + const optionFlags = flags.map((flag) => `"${flag}"`).join("|"); + const escapedChoices = choices + .map((choice) => `'${choice.replaceAll("'", "'\\''")}'`) + .join(" "); + const shouldComplete = requiresValue + ? "true" + : `[[ -n "\${choice_completion_prefix}" || "\${choice_prefix}" != -* ]]`; + return ` ${optionFlags}) + if ${shouldComplete}; then + local -a choice_values=(${escapedChoices}) + local choice + for choice in "\${choice_values[@]}"; do + if [[ "\${choice}" == "\${choice_prefix}"* ]]; then + COMPREPLY+=("\${choice_completion_prefix}\${choice}") + fi + done + return + fi + ;;`; + }) + .join("\n"); + return ` ${commandPaths}) + case "\${choice_flag}" in +${optionCases} + esac + ;;`; + }) + .join("\n"); + return cases ? ` case "\${command_path}" in\n${cases}\n esac\n` : ""; +} + function generateBashCompletionContextCases(contexts: ShellCompletionContext[]): string { const segments = contexts.map((context) => { const patterns = context.pathVariants @@ -459,9 +542,11 @@ ${generateBashCompletionContextCases(contexts)} function generatePowerShellCompletion(program: Command): string { const rootCmd = program.name(); - const segments: string[] = []; + const completionBodies: string[] = []; const formatPowerShellArray = (entries: string[]) => - entries.length > 0 ? `@(${entries.map((entry) => `'${entry}'`).join(",")})` : "@()"; + entries.length > 0 + ? `@(${entries.map((entry) => `'${entry.replaceAll("'", "''")}'`).join(",")})` + : "@()"; const { root, descendants: contexts } = collectShellCompletionCommandTree(program); const rootValueOptions = root.valueOptions; const commandPathCases = contexts @@ -474,6 +559,12 @@ function generatePowerShellCompletion(program: Command): string { ), ) .join("\n"); + const commandPathUpdate = commandPathCases + ? ` $candidatePath = if ($commandPath -eq '') { $element } else { "$commandPath $element" } + switch ($candidatePath) { +${commandPathCases} + }` + : ""; for (const context of contexts) { if (context.completions.length > 0) { @@ -483,7 +574,7 @@ function generatePowerShellCompletion(program: Command): string { if (fullPath.length === 0) { continue; } - segments.push(` + completionBodies.push(` if ($commandPath -eq '${fullPath}') { $completions = ${allCompletions} $completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { @@ -494,7 +585,36 @@ function generatePowerShellCompletion(program: Command): string { } } } - const rootBody = segments.join(""); + const rootBody = completionBodies.join(""); + const choiceCompletion = [root, ...contexts] + .filter(({ valueChoices }) => valueChoices.length > 0) + .flatMap(({ pathVariants, valueChoices }) => + pathVariants.map((pathSegments) => { + const optionChoiceCases = valueChoices + .map( + ({ + flags, + choices, + requiresValue, + }) => ` if ($choiceFlag -in ${formatPowerShellArray(flags)} -and (${requiresValue ? "$true" : "$choiceCompletionPrefix -ne '' -or $choicePrefix -notlike '-*'"})) { + $escapedChoicePrefix = [WildcardPattern]::Escape($choicePrefix) + ${formatPowerShellArray(choices)} | Where-Object { $_ -like "$escapedChoicePrefix*" } | ForEach-Object { + $completionText = "$choiceCompletionPrefix$_" + if ($completionText -notmatch '^[\\p{L}\\p{N}_./:=+-]+$') { + $completionText = "'" + $completionText.Replace("'", "''") + "'" + } + [System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_) + } + return + }`, + ) + .join("\n"); + return ` if ($commandPath -eq '${pathSegments.join(" ").replaceAll("'", "''")}') { +${optionChoiceCases} + }`; + }), + ) + .join("\n"); return ` Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { @@ -503,6 +623,16 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { $commandElements = $commandAst.CommandElements $commandPath = "" $valueOptions = ${formatPowerShellArray(rootValueOptions)} + $previousElementIndex = if ($wordToComplete -eq '') { $commandElements.Count - 1 } else { $commandElements.Count - 2 } + $previousElement = if ($previousElementIndex -ge 1) { $commandElements[$previousElementIndex].Extent.Text } else { '' } + $choiceFlag = $previousElement + $choicePrefix = $wordToComplete + $choiceCompletionPrefix = '' + if ($wordToComplete -match '^(--[^=]+)=(.*)$') { + $choiceFlag = $Matches[1] + $choicePrefix = $Matches[2] + $choiceCompletionPrefix = "$choiceFlag=" + } # Skip option values so global and nested flags cannot hide the command path. for ($i = 1; $i -lt $commandElements.Count; $i++) { @@ -516,11 +646,36 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { continue } - $candidatePath = if ($commandPath -eq '') { $element } else { "$commandPath $element" } - switch ($candidatePath) { -${commandPathCases} +${commandPathUpdate} + } + + if ($previousElement -match '^-[^-].+$') { + $shortGroup = $previousElement.Substring(1) + for ($shortIndex = 0; $shortIndex -lt $shortGroup.Length; $shortIndex++) { + $shortFlag = "-$($shortGroup[$shortIndex])" + if ($valueOptions -contains $shortFlag) { + if ($shortIndex -eq $shortGroup.Length - 1) { + $choiceFlag = $shortFlag + } + break + } } } + + if ($wordToComplete -match '^-[^-].+$') { + $shortGroup = $wordToComplete.Substring(1) + for ($shortIndex = 0; $shortIndex -lt $shortGroup.Length; $shortIndex++) { + $shortFlag = "-$($shortGroup[$shortIndex])" + if ($valueOptions -contains $shortFlag) { + $choiceFlag = $shortFlag + $choicePrefix = $shortGroup.Substring($shortIndex + 1) + $choiceCompletionPrefix = "-$($shortGroup.Substring(0, $shortIndex + 1))" + break + } + } + } + +${choiceCompletion} # Root command if ($commandPath -eq "") { @@ -569,6 +724,8 @@ function generateFishCompletion(program: Command): string { condition, flags: completionFlags(opt), description: opt.description, + requiresValue: opt.required, + choices: opt.argChoices, }), ); } diff --git a/src/cli/completion-command-tree.test.ts b/src/cli/completion-command-tree.test.ts index f09de2aa37dc..f4601a76be83 100644 --- a/src/cli/completion-command-tree.test.ts +++ b/src/cli/completion-command-tree.test.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { describe, expect, it } from "vitest"; import { collectShellCompletionCommandTree } from "./completion-command-tree.js"; @@ -63,6 +63,59 @@ describe("shell completion command tree", () => { expect(tree.descendants[0]?.completions).toEqual(["--profile", "--force"]); }); + it("preserves inherited Commander option choices by their short and long aliases", () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("-p, --profile ").choices(["work", "personal"])); + program + .command("completion") + .addOption(new Option("-s, --shell ").choices(["zsh", "bash", "powershell", "fish"])); + + const tree = collectShellCompletionCommandTree(program); + + expect(tree.root.valueChoices).toEqual([ + { flags: ["-p", "--profile"], choices: ["work", "personal"], requiresValue: true }, + ]); + expect(tree.descendants[0]?.valueChoices).toEqual([ + { flags: ["-p", "--profile"], choices: ["work", "personal"], requiresValue: true }, + { + flags: ["-s", "--shell"], + choices: ["zsh", "bash", "powershell", "fish"], + requiresValue: true, + }, + ]); + }); + + it("keeps unshadowed inherited aliases when a child reuses one option flag", () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("-p, --profile ").choices(["work", "personal"])); + program.command("gateway").option("-p, --port ", "Gateway port"); + + const tree = collectShellCompletionCommandTree(program); + + expect(tree.descendants[0]?.valueChoices).toEqual([ + { flags: ["--profile"], choices: ["work", "personal"], requiresValue: true }, + ]); + }); + + it("preserves the optional Commander argument contract for constrained choices", () => { + const program = new Command() + .name("openclaw") + .addOption(new Option("-c, --color [when]").choices(["always", "never"])); + program.command("gateway"); + + const tree = collectShellCompletionCommandTree(program); + const colorChoice = { + flags: ["-c", "--color"], + choices: ["always", "never"], + requiresValue: false, + }; + + expect(tree.root.valueChoices).toEqual([colorChoice]); + expect(tree.descendants[0]?.valueChoices).toEqual([colorChoice]); + }); + it("keeps commandless roots valid for every shell", () => { const tree = collectShellCompletionCommandTree(new Command().name("openclaw")); diff --git a/src/cli/completion-command-tree.ts b/src/cli/completion-command-tree.ts index a3d2d3e221a8..22364056d80d 100644 --- a/src/cli/completion-command-tree.ts +++ b/src/cli/completion-command-tree.ts @@ -1,10 +1,17 @@ import type { Command, Option } from "commander"; +type ShellCompletionValueChoice = { + flags: string[]; + choices: string[]; + requiresValue: boolean; +}; + export type ShellCompletionContext = { command: Command; pathVariants: string[][]; completions: string[]; valueOptions: string[]; + valueChoices: ShellCompletionValueChoice[]; }; type ShellCompletionCommandTree = { @@ -27,7 +34,9 @@ export function collectShellCompletionCommandTree(program: Command): ShellComple command: Command, pathVariants: string[][], inheritedValueOptions: readonly string[], + inheritedValueChoices: readonly ShellCompletionValueChoice[], ): ShellCompletionContext => { + const ownOptionFlags = new Set(command.options.flatMap(completionFlags)); const context: ShellCompletionContext = { command, pathVariants, @@ -43,6 +52,23 @@ export function collectShellCompletionCommandTree(program: Command): ShellComple ), ]), ], + valueChoices: [ + ...inheritedValueChoices.flatMap(({ flags, ...choice }) => { + const inheritedFlags = flags.filter((flag) => !ownOptionFlags.has(flag)); + return inheritedFlags.length > 0 ? [{ flags: inheritedFlags, ...choice }] : []; + }), + ...command.options.flatMap((option) => + option.argChoices?.length + ? [ + { + flags: completionFlags(option), + choices: [...option.argChoices], + requiresValue: option.required, + }, + ] + : [], + ), + ], }; if (pathVariants[0]?.length) { @@ -56,11 +82,12 @@ export function collectShellCompletionCommandTree(program: Command): ShellComple commandNameVariants(child).map((name) => parents.concat(name)), ), context.valueOptions, + context.valueChoices, ); } return context; }; - return { root: visit(program, [[]], []), descendants }; + return { root: visit(program, [[]], [], []), descendants }; } diff --git a/src/cli/completion-fish.test.ts b/src/cli/completion-fish.test.ts index c8d89faa4eb7..86cad0cb6211 100644 --- a/src/cli/completion-fish.test.ts +++ b/src/cli/completion-fish.test.ts @@ -1,4 +1,5 @@ // Fish completion tests cover fish shell completion script generation. +import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { buildFishOptionCompletionLine, @@ -53,4 +54,120 @@ describe("completion-fish helpers", () => { `complete -c openclaw -n "__fish_use_subcommand" -l ws -l workspace -d 'Workspace'\n`, ); }); + + it("preserves required Commander option values and constrained choices", () => { + const line = buildFishOptionCompletionLine({ + rootCmd: "openclaw", + condition: "__fish_seen_subcommand_from completion", + flags: ["-s", "--shell"], + description: "Shell target", + requiresValue: true, + choices: ["zsh", "bash", "powershell", "fish"], + }); + + const quotedChoices = ["zsh", "bash", "powershell", "fish"] + .map((choice) => `'${choice}'`) + .join(" "); + expect(line).toBe( + `complete -c openclaw -n "__fish_seen_subcommand_from completion" -s s -l shell -r -f -a "${quotedChoices}" -d 'Shell target'\n`, + ); + }); + + it("preserves optional Commander option values without requiring an argument", () => { + const line = buildFishOptionCompletionLine({ + rootCmd: "openclaw", + condition: "__fish_use_subcommand", + flags: ["--color"], + description: "Color output", + choices: ["always", "never"], + }); + + expect(line).toContain(` -l color -f -a "'always' 'never'" `); + expect(line).not.toContain(" -r "); + expect(line).toContain( + `complete -c openclaw -n "__fish_use_subcommand; and contains -- (commandline -opc)[-1] --color" -f -a "'always' 'never'" -d 'Color output'`, + ); + }); + + it("preserves whitespace within each Commander choice", () => { + const line = buildFishOptionCompletionLine({ + rootCmd: "openclaw", + condition: "__fish_use_subcommand", + flags: ["--theme"], + description: "Theme", + requiresValue: true, + choices: ["light blue", "dark"], + }); + + expect(line).toContain(` -r -f -a "'light blue' 'dark'" `); + }); + + it.skipIf(process.platform === "win32")( + "preserves apostrophes and backslashes through both Fish quoting layers", + () => { + const line = buildFishOptionCompletionLine({ + rootCmd: "openclaw", + condition: "true", + flags: ["--theme"], + description: "Theme", + requiresValue: true, + choices: ["Bob's green", "path\\name", "ends\\"], + }); + + const capture = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `complete() { while (( $# )); do if [[ "$1" == "-a" ]]; then printf '%s' "$2"; return; fi; shift; done; }\n${line}`, + ], + { encoding: "utf8" }, + ); + expect(capture.status).toBe(0); + expect(capture.stderr).toBe(""); + expect(capture.stdout).toBe("'Bob\\'s green' 'path\\\\name' 'ends\\\\'"); + }, + ); + + it.skipIf(process.platform === "win32")( + "keeps command-shaped Commander choices inert during completion expression parsing", + () => { + const marker = "OPENCLAW_FISH_CHOICE_MUST_NOT_EXECUTE"; + const line = buildFishOptionCompletionLine({ + rootCmd: "openclaw", + condition: "true", + flags: ["--channel"], + description: "Channel", + requiresValue: true, + choices: ["(stable)", `$(printf ${marker} >&2)`, "light blue"], + }); + + const capture = spawnSync( + "bash", + [ + "--noprofile", + "--norc", + "-c", + `complete() { while (( $# )); do if [[ "$1" == "-a" ]]; then printf '%s' "$2"; return; fi; shift; done; }\n${line}`, + ], + { encoding: "utf8" }, + ); + expect(capture.status).toBe(0); + expect(capture.stderr).toBe(""); + + const evaluation = spawnSync( + "bash", + ["--noprofile", "--norc", "-c", `set -- ${capture.stdout}; printf '%s\\n' "$@"`], + { encoding: "utf8" }, + ); + expect(evaluation.status).toBe(0); + expect(evaluation.stderr).toBe(""); + expect(evaluation.stdout.split("\n").filter(Boolean)).toEqual([ + "(stable)", + `$(printf ${marker} >&2)`, + "light blue", + ]); + }, + ); }); diff --git a/src/cli/completion-fish.ts b/src/cli/completion-fish.ts index ce903c3a8ca9..f9a1bfc2e535 100644 --- a/src/cli/completion-fish.ts +++ b/src/cli/completion-fish.ts @@ -3,6 +3,15 @@ function escapeFishDescription(value: string): string { return value.replace(/'/g, "'\\''"); } +function quoteFishCompletionChoice(value: string): string { + // Fish evaluates -a expressions during completion; single quotes keep choices as inert values. + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; +} + +function escapeFishDoubleQuotedArgument(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$"); +} + export function buildFishSubcommandCompletionLine(params: { rootCmd: string; condition: string; @@ -18,12 +27,28 @@ export function buildFishOptionCompletionLine(params: { condition: string; flags: readonly string[]; description: string; + requiresValue?: boolean; + choices?: readonly string[]; }): string { const desc = escapeFishDescription(params.description); + const choices = params.choices?.length + ? escapeFishDoubleQuotedArgument(params.choices.map(quoteFishCompletionChoice).join(" ")) + : undefined; let line = `complete -c ${params.rootCmd} -n "${params.condition}"`; for (const flag of params.flags) { line += flag.startsWith("--") ? ` -l ${flag.slice(2)}` : ` -s ${flag.slice(1)}`; } + if (params.requiresValue) { + line += " -r"; + } + if (choices) { + line += ` -f -a "${choices}"`; + } line += ` -d '${desc}'\n`; + if (choices && !params.requiresValue) { + // Fish only binds separated values to required options; keep Commander optional values optional. + const pendingOption = `contains -- (commandline -opc)[-1] ${params.flags.join(" ")}`; + line += `complete -c ${params.rootCmd} -n "${params.condition}; and ${pendingOption}" -f -a "${choices}" -d '${desc}'\n`; + } return line; } diff --git a/src/cli/completion-runtime.test.ts b/src/cli/completion-runtime.test.ts index 9b0eb62276b2..47c5f7300036 100644 --- a/src/cli/completion-runtime.test.ts +++ b/src/cli/completion-runtime.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { withEnvAsync } from "../test-utils/env.js"; import { + COMPLETION_SHELLS, formatCompletionReloadCommand, installCompletion, isCompletionInstalled, @@ -80,6 +81,80 @@ describe("completion-runtime", () => { }); }); + it("does not mistake an orphaned completion marker for an installed profile", async () => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8"); + await fs.writeFile( + path.join(homeDir, ".bash_profile"), + "# OpenClaw Completion\nexport IMPORTANT=keep\n", + "utf-8", + ); + + await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(false); + }); + }); + + it("recognizes an installed profile when its completion cache has been removed", async () => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + await fs.writeFile( + path.join(homeDir, ".bash_profile"), + `# OpenClaw Completion\n[ -f "${cachePath}" ] && source "${cachePath}"\n`, + "utf-8", + ); + + await expect(isCompletionInstalled("bash", "openclaw")).resolves.toBe(true); + }); + }); + + it.each(COMPLETION_SHELLS)( + "replaces the old generated %s source after the state directory changes", + async (shell) => { + await withBashCompletionHome(async ({ stateDir }) => { + const previousStateDir = tempDirs.make("openclaw-completion-previous-state-"); + let previousCachePath = ""; + await withEnvAsync({ OPENCLAW_STATE_DIR: previousStateDir }, async () => { + previousCachePath = resolveCompletionCachePath(shell, "openclaw"); + await fs.mkdir(path.dirname(previousCachePath), { recursive: true }); + await fs.writeFile(previousCachePath, "# previous completion\n", "utf-8"); + await installCompletion(shell, true, "openclaw"); + }); + + const currentCachePath = resolveCompletionCachePath(shell, "openclaw"); + expect(currentCachePath).toContain(stateDir); + await fs.mkdir(path.dirname(currentCachePath), { recursive: true }); + await fs.writeFile(currentCachePath, "# current completion\n", "utf-8"); + await installCompletion(shell, true, "openclaw"); + + const profile = await fs.readFile(resolveCompletionProfilePath(shell), "utf-8"); + expect(profile).toContain(currentCachePath); + expect(profile).not.toContain(previousCachePath); + expect(profile.match(/^# OpenClaw Completion$/gm)).toHaveLength(1); + }); + }, + ); + + it("preserves unrelated generated-looking sources that are not owned by its profile marker", async () => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + const profilePath = path.join(homeDir, ".bash_profile"); + const unrelatedSource = 'source "/opt/tools/completions/openclaw.zsh"'; + const unmarkedPriorSource = 'source "/opt/tools/completions/openclaw.bash"'; + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "# current completion\n", "utf-8"); + await fs.writeFile(profilePath, `${unrelatedSource}\n${unmarkedPriorSource}\n`, "utf-8"); + + await installCompletion("bash", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).toContain(`${unrelatedSource}\n`); + expect(profile).toContain(`${unmarkedPriorSource}\n`); + expect(profile).toContain(cachePath); + }); + }); + it("prefers an existing .bashrc over the Bash login profile", async () => { await withBashCompletionHome(async ({ homeDir }) => { const bashrc = path.join(homeDir, ".bashrc"); @@ -102,6 +177,142 @@ describe("completion-runtime", () => { }); }); + it("preserves unrelated profile lines while replacing orphaned completion blocks", async () => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + const profilePath = path.join(homeDir, ".bash_profile"); + const refreshAlias = "alias refresh_openclaw='openclaw completion --write-state'"; + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8"); + await fs.writeFile( + profilePath, + `# OpenClaw Completion\nexport IMPORTANT=keep\n${refreshAlias}\n`, + "utf-8", + ); + + await installCompletion("bash", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).toContain("export IMPORTANT=keep\n"); + expect(profile).toContain(`${refreshAlias}\n`); + expect(profile.match(/^# OpenClaw Completion$/gm)).toHaveLength(1); + expect(profile).toContain(cachePath); + }); + }); + + it("replaces documented dynamic completion without deleting unrelated aliases", async () => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + const profilePath = path.join(homeDir, ".bash_profile"); + const refreshAlias = "alias refresh_openclaw='openclaw completion --write-state'"; + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8"); + await fs.writeFile( + profilePath, + `export IMPORTANT=keep\nsource <(openclaw completion --shell bash)\n${refreshAlias}\n`, + "utf-8", + ); + + await installCompletion("bash", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).toContain("export IMPORTANT=keep\n"); + expect(profile).toContain(`${refreshAlias}\n`); + expect(profile).not.toContain("source <(openclaw completion"); + expect(profile).toContain(cachePath); + }); + }); + + it.each([ + "export IMPORTANT=keep; source <(openclaw completion --shell bash)", + "source <(openclaw completion --shell bash); export IMPORTANT=keep", + 'source <(openclaw completion --shell bash) >"$HOME/completion.log"', + 'eval "$(openclaw completion --shell bash)" >"$HOME/completion.log"', + 'source <(openclaw completion --shell bash >"$HOME/completion.log")', + ])("preserves compound user-owned Bash profile statements: %s", async (compoundLine) => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + const profilePath = path.join(homeDir, ".bash_profile"); + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8"); + await fs.writeFile(profilePath, `${compoundLine}\n`, "utf-8"); + + await installCompletion("bash", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).toContain(`${compoundLine}\n`); + expect(profile).toContain(`[ -f "${cachePath}" ] && source "${cachePath}"`); + }); + }); + + it.each([ + { + name: "dot-sourced process substitution", + sourceLine: ". <(openclaw completion --shell bash)", + }, + { + name: "eval command substitution", + sourceLine: 'eval "$(openclaw completion --shell bash)"', + }, + ])("replaces $name without deleting unrelated aliases", async ({ sourceLine }) => { + await withBashCompletionHome(async ({ homeDir }) => { + const cachePath = resolveCompletionCachePath("bash", "openclaw"); + const profilePath = path.join(homeDir, ".bash_profile"); + const refreshAlias = "alias refresh_openclaw='openclaw completion --write-state'"; + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8"); + await fs.writeFile(profilePath, `${sourceLine}\n${refreshAlias}\n`, "utf-8"); + + await installCompletion("bash", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).not.toContain(sourceLine); + expect(profile).toContain(`${refreshAlias}\n`); + expect(profile).toContain(cachePath); + }); + }); + + it("replaces PowerShell dynamic pipelines without deleting unrelated command strings", async () => { + await withBashCompletionHome(async () => { + const cachePath = resolveCompletionCachePath("powershell", "openclaw"); + const profilePath = resolveCompletionProfilePath("powershell"); + const dynamicLine = "openclaw completion --shell powershell | Out-String | Invoke-Expression"; + const refreshCommand = '$refresh = "openclaw completion --write-state"'; + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "# PowerShell completion\n", "utf-8"); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + await fs.writeFile(profilePath, `${dynamicLine}\n${refreshCommand}\n`, "utf-8"); + + await expect(usesSlowDynamicCompletion("powershell", "openclaw")).resolves.toBe(true); + await installCompletion("powershell", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).not.toContain(dynamicLine); + expect(profile).toContain(`${refreshCommand}\n`); + expect(profile).toContain(cachePath); + }); + }); + + it.each([ + "openclaw completion --shell powershell | Out-String | Invoke-Expression; $env:IMPORTANT = 'keep'", + 'openclaw completion --shell powershell | Tee-Object "$HOME/generated.ps1" | Out-String | Invoke-Expression', + ])("preserves compound user-owned PowerShell profile statements: %s", async (compoundLine) => { + await withBashCompletionHome(async () => { + const cachePath = resolveCompletionCachePath("powershell", "openclaw"); + const profilePath = resolveCompletionProfilePath("powershell"); + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "# PowerShell completion\n", "utf-8"); + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + await fs.writeFile(profilePath, `${compoundLine}\n`, "utf-8"); + + await installCompletion("powershell", true, "openclaw"); + + const profile = await fs.readFile(profilePath, "utf-8"); + expect(profile).toContain(`${compoundLine}\n`); + expect(profile).toContain(`. '${cachePath}'`); + }); + }); + it("formats PowerShell reload commands with single-quoted paths", () => { expect(formatCompletionReloadCommand("powershell", "C:\\Users\\Ada\\profile.ps1")).toBe( ". 'C:\\Users\\Ada\\profile.ps1'", diff --git a/src/cli/completion-runtime.ts b/src/cli/completion-runtime.ts index 9b60561b0b52..538b8f2c35d3 100644 --- a/src/cli/completion-runtime.ts +++ b/src/cli/completion-runtime.ts @@ -62,12 +62,14 @@ function resolveCompletionCacheDir(env: NodeJS.ProcessEnv = process.env): string return path.join(stateDir, "completions"); } +function completionShellExtension(shell: CompletionShell): string { + return shell === "powershell" ? "ps1" : shell; +} + /** Returns the per-shell cached completion script path for a sanitized CLI binary name. */ export function resolveCompletionCachePath(shell: CompletionShell, binName: string): string { const basename = sanitizeCompletionBasename(binName); - const extension = - shell === "powershell" ? "ps1" : shell === "fish" ? "fish" : shell === "bash" ? "bash" : "zsh"; - return path.join(resolveCompletionCacheDir(), `${basename}.${extension}`); + return path.join(resolveCompletionCacheDir(), `${basename}.${completionShellExtension(shell)}`); } /** Check if the completion cache file exists for the given shell. */ @@ -106,20 +108,110 @@ function isCompletionProfileHeader(line: string): boolean { } function isCompletionProfileLine(line: string, binName: string, cachePath: string | null): boolean { - if (line.includes(`${binName} completion`)) { + if (isSlowDynamicCompletionLine(line, binName)) { return true; } - if (cachePath && line.includes(cachePath)) { - return true; + if (!cachePath) { + return false; } - return false; + const trimmed = line.trim(); + return ( + trimmed === `source "${cachePath}"` || + COMPLETION_SHELLS.some((shell) => trimmed === formatCompletionSourceLine(shell, cachePath)) + ); } -/** Check if a line uses the slow dynamic completion pattern (source <(...)) */ -function isSlowDynamicCompletionLine(line: string, binName: string): boolean { +function isPreviousCompletionSourceLine(line: string, currentCachePath: string | null): boolean { + if (!currentCachePath) { + return false; + } + const trimmed = line.trim(); + const guarded = + /^(?:\[\s+-f|test\s+-f)\s+"([^"]+)"\s*(?:\]\s*&&|;\s*and)\s+source\s+"([^"]+)"$/u.exec(trimmed); + const direct = /^source\s+"([^"]+)"$/u.exec(trimmed); + const powershell = /^\.\s+'((?:[^']|'')+)'$/u.exec(trimmed); + let sourcePath: string | undefined; + if (guarded && guarded[1] === guarded[2]) { + sourcePath = guarded[1]; + } else if (direct) { + sourcePath = direct[1]; + } else if (powershell) { + sourcePath = powershell[1]?.replace(/''/g, "'"); + } + if (!sourcePath) { + return false; + } + const sourcePaths = sourcePath.includes("\\") ? path.win32 : path; + if (sourcePaths.basename(sourcePaths.dirname(sourcePath)) !== "completions") { + return false; + } + return sourcePaths.basename(sourcePath) === path.basename(currentCachePath); +} + +function isOwnedCompletionInvocation(invocation: string, binName: string): boolean { + const [command, action, ...args] = invocation.trim().split(/\s+/u); + if (command !== binName || action !== "completion") { + return false; + } + if (args.length === 0) { + return true; + } + if (args.length === 1) { + const argument = args[0] ?? ""; + const shell = argument.startsWith("--shell=") + ? argument.slice("--shell=".length) + : argument.startsWith("-s") && argument.length > 2 + ? argument.slice(2).replace(/^=/u, "") + : argument; + return isCompletionShell(shell); + } return ( - line.includes(`<(${binName} completion`) || - (line.includes(`${binName} completion`) && line.includes("| source")) + args.length === 2 && + (args[0] === "--shell" || args[0] === "-s") && + isCompletionShell(args[1] ?? "") + ); +} + +/** Check if a line uses an owned slow dynamic completion pattern (source <(...)). */ +function isSlowDynamicCompletionLine(line: string, binName: string): boolean { + const trimmed = line.trim(); + const dynamicMarker = `<(${binName} completion`; + const markerIndex = trimmed.indexOf(dynamicMarker); + if (markerIndex >= 0) { + const expression = trimmed.slice(markerIndex); + // Compound profile statements are user-owned; deleting the entire line loses their commands. + return ( + /^(?:(?:\[\s+-f\s+[^\]]+\]\s*&&\s*)?(?:source|\.))\s*$/u.test( + trimmed.slice(0, markerIndex).trimEnd(), + ) && + expression.endsWith(")") && + isOwnedCompletionInvocation(expression.slice(2, -1), binName) + ); + } + const invocationIndex = trimmed.indexOf(`${binName} completion`); + if (invocationIndex < 0) { + return false; + } + const invocationPrefix = trimmed.slice(0, invocationIndex).trimEnd(); + const evalPrefix = /^eval\s+(["']?)\$\($/u.exec(invocationPrefix); + if (evalPrefix) { + const invocation = trimmed.slice(invocationIndex); + const closing = `)${evalPrefix[1] ?? ""}`; + return ( + invocation.endsWith(closing) && + isOwnedCompletionInvocation(invocation.slice(0, -closing.length), binName) + ); + } + if (invocationIndex !== 0 || /[;&]/u.test(trimmed)) { + return false; + } + const pipeline = trimmed.split("|").map((stage) => stage.trim()); + const terminal = pipeline.at(-1) ?? ""; + // Only the documented optional Out-String stage is owned by completion migration. + return ( + isOwnedCompletionInvocation(pipeline[0] ?? "", binName) && + /^(?:source|Invoke-Expression|iex)$/iu.test(terminal) && + (pipeline.length === 2 || (pipeline.length === 3 && /^Out-String$/iu.test(pipeline[1] ?? ""))) ); } @@ -138,7 +230,14 @@ function updateCompletionProfile( const line = lines[i] ?? ""; if (isCompletionProfileHeader(line)) { hadExisting = true; - i += 1; + // An orphaned marker owns no following user line; remove only a recognized source line. + const following = lines[i + 1] ?? ""; + if ( + isCompletionProfileLine(following, binName, cachePath) || + isPreviousCompletionSourceLine(following, cachePath) + ) { + i += 1; + } continue; } if (isCompletionProfileLine(line, binName, cachePath)) { @@ -202,13 +301,11 @@ export async function isCompletionInstalled( if (!(await pathExists(profilePath))) { return false; } - const cachePathCandidate = resolveCompletionCachePath(shell, binName); - const cachedPath = (await pathExists(cachePathCandidate)) ? cachePathCandidate : null; + const cachePath = resolveCompletionCachePath(shell, binName); const content = await fs.readFile(profilePath, "utf-8"); const lines = content.split("\n"); - return lines.some( - (line) => isCompletionProfileHeader(line) || isCompletionProfileLine(line, binName, cachedPath), - ); + // A marker does not install completion; retain missing-cache source lines for doctor repair. + return lines.some((line) => isCompletionProfileLine(line, binName, cachePath)); } /** diff --git a/src/commands/doctor-completion.test.ts b/src/commands/doctor-completion.test.ts index 48954cf42587..911a119772ac 100644 --- a/src/commands/doctor-completion.test.ts +++ b/src/commands/doctor-completion.test.ts @@ -87,6 +87,31 @@ describe("shell completion health mapping", () => { }); }); + it("reports an orphaned shell-completion marker as uninstalled", async () => { + const homeDir = tempDirs.make("openclaw-bash-orphaned-profile-home-"); + const stateDir = tempDirs.make("openclaw-bash-orphaned-profile-state-"); + setTestEnvValue("HOME", homeDir); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue("SHELL", "/bin/bash"); + + const cachePath = path.join(stateDir, "completions", "openclaw.bash"); + await fs.mkdir(path.dirname(cachePath), { recursive: true }); + await fs.writeFile(cachePath, "complete -W 'status' openclaw\n", "utf-8"); + await fs.writeFile( + path.join(homeDir, ".bash_profile"), + "# OpenClaw Completion\nexport IMPORTANT=keep\n", + "utf-8", + ); + + await expect(checkShellCompletionStatus("openclaw", { shell: "bash" })).resolves.toEqual({ + shell: "bash", + profileInstalled: false, + cacheExists: true, + cachePath, + usesSlowPattern: false, + }); + }); + it("checks an explicit shell instead of the detected environment shell", async () => { const homeDir = tempDirs.make("openclaw-completion-home-"); const stateDir = tempDirs.make("openclaw-completion-state-");