diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts index 289a657e4c1..1581c495893 100644 --- a/src/cli/completion-cli.test.ts +++ b/src/cli/completion-cli.test.ts @@ -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 ", "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,"); }); }); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 9202f00b635..09a2e5643d7 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -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()]); } };