fix(cli): preserve completion profiles and option value contracts (#116906)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 08:59:11 -07:00
committed by GitHub
parent 87d5cb9f67
commit f66fff57f3
9 changed files with 927 additions and 32 deletions
+187 -4
View File
@@ -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 <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 <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 <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 <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'",
);
});
+166 -9
View File
@@ -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,
}),
);
}
+54 -1
View File
@@ -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 <name>").choices(["work", "personal"]));
program
.command("completion")
.addOption(new Option("-s, --shell <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 <name>").choices(["work", "personal"]));
program.command("gateway").option("-p, --port <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"));
+28 -1
View File
@@ -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 };
}
+117
View File
@@ -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",
]);
},
);
});
+25
View File
@@ -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;
}
+211
View File
@@ -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'",
+114 -17
View File
@@ -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));
}
/**
+25
View File
@@ -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-");