mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(cli): preserve completion profiles and option value contracts (#116906)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
87d5cb9f67
commit
f66fff57f3
@@ -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
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user