fix(cli): harden generated completions

This commit is contained in:
Peter Steinberger
2026-05-24 01:04:01 +01:00
parent 459cee5315
commit 308af85991
2 changed files with 94 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ function createCompletionProgram(): Command {
const gateway = program.command("gateway").description("Gateway commands");
gateway.option("--force", "Force the action");
gateway.option("-t, --token <token>", "Gateway token");
gateway.command("status").description("Show gateway status").option("--json", "JSON output");
gateway.command("restart").description("Restart gateway");
@@ -90,7 +91,8 @@ describe("completion-cli", () => {
expect(script).toContain("if ($commandPath -eq 'gateway') {");
expect(script).toContain("if ($commandPath -eq 'gateway status') {");
expect(script).not.toContain("if ($commandPath -eq 'openclaw gateway') {");
expect(script).toContain("$completions = @('status','restart','--force')");
expect(script).toContain("$completions = @('status','restart','--force','--token')");
expect(script).not.toContain("'-t,'");
});
it("generates fish completions for root and nested command contexts", () => {
@@ -100,10 +102,21 @@ describe("completion-cli", () => {
'complete -c openclaw -n "__fish_use_subcommand" -a "gateway" -d \'Gateway commands\'',
);
expect(script).toContain(
'complete -c openclaw -n "__fish_seen_subcommand_from gateway" -a "status" -d \'Show gateway status\'',
'complete -c openclaw -n "__openclaw_command_path_matches gateway" -a "status" -d \'Show gateway status\'',
);
expect(script).toContain(
"complete -c openclaw -n \"__fish_seen_subcommand_from gateway\" -l force -d 'Force the action'",
"complete -c openclaw -n \"__openclaw_command_path_matches gateway\" -l force -d 'Force the action'",
);
expect(script).toContain(
"complete -c openclaw -n \"__openclaw_command_path_matches gateway status\" -l json -d 'JSON output'",
);
expect(script).toContain("set -l root_boolean_options -v --verbose");
});
it("generates Bash completions without comma-suffixed short flags", () => {
const script = getCompletionScript("bash", createCompletionProgram());
expect(script).toContain("--token");
expect(script).not.toContain("-t,");
});
});

View File

@@ -34,6 +34,75 @@ export function getCompletionScript(shell: CompletionShell, program: Command): s
return generateFishCompletion(program);
}
function splitOptionFlags(flags: string): string[] {
return flags.split(/[ ,|]+/u).filter(Boolean);
}
function preferredCompletionFlag(flags: string): string {
const parts = splitOptionFlags(flags);
return parts.find((flag) => flag.startsWith("--")) ?? parts[0] ?? flags;
}
function fishWords(values: readonly string[]): string {
return values.join(" ");
}
function fishOptionFlags(options: Command["options"], wantsValue: boolean): string[] {
return options.flatMap((option) => {
if (Boolean(option.required || option.optional) !== wantsValue) {
return [];
}
return splitOptionFlags(option.flags).filter((flag) => flag.startsWith("-"));
});
}
function generateFishPathHelper(program: Command, rootCmd: string): string {
const rootValueOptions = fishOptionFlags(program.options, true);
const rootBooleanOptions = fishOptionFlags(program.options, false);
return `
function __${rootCmd}_command_path_matches
set -l tokens (commandline -opc)
set -e tokens[1]
set -l root_value_options ${fishWords(rootValueOptions)}
set -l root_boolean_options ${fishWords(rootBooleanOptions)}
set -l command_tokens
set -l skip_next 0
for token in $tokens
if test $skip_next -eq 1
set skip_next 0
continue
end
if test (count $command_tokens) -eq 0
set -l flag (string split -m1 "=" -- $token)[1]
if contains -- $flag $root_boolean_options
continue
end
if contains -- $flag $root_value_options
if not string match -q -- "*=*" $token
set skip_next 1
end
continue
end
end
if string match -q -- "-*" $token
continue
end
set -a command_tokens $token
end
for i in (seq (count $argv))
if test "$command_tokens[$i]" != "$argv[$i]"
return 1
end
end
return 0
end
`;
}
function fishCommandPathCondition(rootCmd: string, parents: readonly string[]): string {
return `__${rootCmd}_command_path_matches ${parents.join(" ")}`;
}
async function writeCompletionCache(params: {
program: Command;
shells: CompletionShell[];
@@ -285,7 +354,7 @@ _${rootCmd}_completion() {
prev="\${COMP_WORDS[COMP_CWORD-1]}"
# Simple top-level completion for now
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => preferredCompletionFlag(o.flags)).join(" ")}"
case "\${prev}" in
${program.commands.map((cmd) => generateBashSubcommand(cmd)).join("\n ")}
@@ -307,7 +376,7 @@ function generateBashSubcommand(cmd: Command): string {
// This is a naive implementation; fully recursive bash completion is complex to generate as a single string without improved state tracking.
// For now, let's provide top-level command recognition.
return `${cmd.name()})
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => preferredCompletionFlag(o.flags)).join(" ")}"
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
return 0
;;`;
@@ -322,7 +391,7 @@ function generatePowerShellCompletion(program: Command): string {
// Command completion for this level
const subCommands = cmd.commands.map((c) => c.name());
const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag
const options = cmd.options.map((o) => preferredCompletionFlag(o.flags));
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
if (fullPath.length > 0 && allCompletions.length > 0) {
@@ -363,7 +432,7 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
# Root command
if ($commandPath -eq "") {
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${o.flags.split(" ")[0]}'`).join(",")})
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${preferredCompletionFlag(o.flags)}'`).join(",")})
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
}
@@ -376,11 +445,9 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
function generateFishCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [];
const segments: string[] = [generateFishPathHelper(program, rootCmd)];
const visit = (cmd: Command, parents: string[]) => {
const cmdName = cmd.name();
// Root logic
if (parents.length === 0) {
// Subcommands of root
@@ -406,12 +473,13 @@ function generateFishCompletion(program: Command): string {
);
}
} else {
const condition = fishCommandPathCondition(rootCmd, parents);
// Subcommands
for (const sub of cmd.commands) {
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
condition,
name: sub.name(),
description: sub.description(),
}),
@@ -422,7 +490,7 @@ function generateFishCompletion(program: Command): string {
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
condition,
flags: opt.flags,
description: opt.description,
}),
@@ -431,7 +499,7 @@ function generateFishCompletion(program: Command): string {
}
for (const sub of cmd.commands) {
visit(sub, [...parents, cmdName]);
visit(sub, parents.length === 0 ? [sub.name()] : [...parents, sub.name()]);
}
};