From b43b128db43c21c4ace96885d259e5aecb387c29 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:51:48 +0100 Subject: [PATCH 1/5] fix(autocomplete): rejoin trailing colon tokens --- internal/autocomplete/autocomplete.go | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 97fe1a81..0cafa378 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -340,17 +340,8 @@ func rebuildColonSeparatedArgs(args []string) []string { // Keep joining while the next element is ":" or the current element ends with ":" for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) { - if args[i+1] == ":" { - current += ":" - i++ - // Check if there's a following element after the ":" - if i+1 < len(args) && args[i+1] != ":" { - current += args[i+1] - i++ - } - } else { - break - } + current += args[i+1] + i++ } result = append(result, current) From 08c2e73de18ff82b5e55c8e08fb2b2287dcf9455 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:51:57 +0100 Subject: [PATCH 2/5] test(autocomplete): cover colon argument reconstruction --- internal/autocomplete/colon_args_test.go | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 internal/autocomplete/colon_args_test.go diff --git a/internal/autocomplete/colon_args_test.go b/internal/autocomplete/colon_args_test.go new file mode 100644 index 00000000..0d2c8598 --- /dev/null +++ b/internal/autocomplete/colon_args_test.go @@ -0,0 +1,40 @@ +package autocomplete + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRebuildColonSeparatedArgs(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + want []string + }{ + "standalone colon": { + args: []string{"a", "b", ":", "c", "d"}, + want: []string{"a", "b:c", "d"}, + }, + "trailing colon": { + args: []string{"config:", "get"}, + want: []string{"config:get"}, + }, + "repeated colons": { + args: []string{"a", ":", ":", "b"}, + want: []string{"a::b"}, + }, + "ordinary arguments": { + args: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.want, rebuildColonSeparatedArgs(test.args)) + }) + } +} From 60fc50b09a916f110ab097aa9c7e8fb5e470a98a Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:55:06 +0100 Subject: [PATCH 3/5] fix(autocomplete): only rejoin trailing colons for commands --- internal/autocomplete/autocomplete.go | 48 ++++++++++++++++++------ internal/autocomplete/colon_args_test.go | 16 +++++++- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 0cafa378..5d78dc93 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -283,7 +283,7 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error { root := cmd.Root() - args := rebuildColonSeparatedArgs(root.Args().Slice()[1:]) + args := rebuildColonSeparatedArgs(root, root.Args().Slice()[1:]) var completionStyle CompletionStyle if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok { @@ -321,13 +321,12 @@ func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error { return cli.Exit("", int(result.Behavior)) } -// When CLI arguments are passed in, they are separated on word barriers. -// Most commonly this is whitespace but in some cases that may also be colons. -// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring -// arguments. -// -// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]` -func rebuildColonSeparatedArgs(args []string) []string { +// When CLI arguments are passed in, shell word breaking can split a colon +// command into adjacent tokens. Rejoin explicit colon separators, and only +// rejoin a token that already ends in ':' when the combined value can still +// name a command. This avoids swallowing an ordinary following argument such +// as `--instructions Prefix: --model`. +func rebuildColonSeparatedArgs(root *cli.Command, args []string) []string { if len(args) == 0 { return args } @@ -338,10 +337,23 @@ func rebuildColonSeparatedArgs(args []string) []string { for i < len(args) { current := args[i] - // Keep joining while the next element is ":" or the current element ends with ":" - for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) { - current += args[i+1] - i++ + for i+1 < len(args) { + next := args[i+1] + if next == ":" { + current += next + i++ + if i+1 < len(args) && args[i+1] != ":" { + current += args[i+1] + i++ + } + continue + } + if strings.HasSuffix(current, ":") && hasCommandPrefix(root, current+next) { + current += next + i++ + continue + } + break } result = append(result, current) @@ -350,3 +362,15 @@ func rebuildColonSeparatedArgs(args []string) []string { return result } + +func hasCommandPrefix(cmd *cli.Command, prefix string) bool { + if cmd == nil { + return false + } + for _, child := range cmd.Commands { + if strings.HasPrefix(child.Name, prefix) || hasCommandPrefix(child, prefix) { + return true + } + } + return false +} diff --git a/internal/autocomplete/colon_args_test.go b/internal/autocomplete/colon_args_test.go index 0d2c8598..ead7600c 100644 --- a/internal/autocomplete/colon_args_test.go +++ b/internal/autocomplete/colon_args_test.go @@ -4,11 +4,17 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" ) func TestRebuildColonSeparatedArgs(t *testing.T) { t.Parallel() + root := &cli.Command{Commands: []*cli.Command{ + {Name: "config:get"}, + {Name: "config:set"}, + }} + tests := map[string]struct { args []string want []string @@ -29,12 +35,20 @@ func TestRebuildColonSeparatedArgs(t *testing.T) { args: []string{"a", "b", "c"}, want: []string{"a", "b", "c"}, }, + "colon-ending value before flag": { + args: []string{"--instructions", "Prefix:", "--mo"}, + want: []string{"--instructions", "Prefix:", "--mo"}, + }, + "colon-ending ordinary value": { + args: []string{"Prefix:", "value"}, + want: []string{"Prefix:", "value"}, + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { t.Parallel() - assert.Equal(t, test.want, rebuildColonSeparatedArgs(test.args)) + assert.Equal(t, test.want, rebuildColonSeparatedArgs(root, test.args)) }) } } From cda24a84ad4bdd33c8f3a8819fe83495b0973bbf Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:33:50 +0100 Subject: [PATCH 4/5] fix(autocomplete): preserve flag value boundaries Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- cmd/openai/main_requestheaders_test.go | 5 +++++ internal/autocomplete/autocomplete.go | 23 +++++++++++++++++++++++ internal/autocomplete/colon_args_test.go | 17 +++++++++++++---- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/cmd/openai/main_requestheaders_test.go b/cmd/openai/main_requestheaders_test.go index 215a132d..f2a08892 100644 --- a/cmd/openai/main_requestheaders_test.go +++ b/cmd/openai/main_requestheaders_test.go @@ -327,6 +327,11 @@ func TestMainRequestHeadersHelpAndCompletion(t *testing.T) { t.Errorf("header help = %q, want the environment input documented", got.stdout) } } + got := runMainDispatch(t, "zsh", "openai", "__complete", "--header", "chat:", "completions", "create", "--mo") + if got.code != 0 || !strings.Contains(got.stdout, "--model:") { + t.Errorf("zsh completion after colon-ending header = %+v, want --model suggestion", got) + } + for _, style := range []string{"bash", "zsh", "fish", "pwsh"} { t.Run(style, func(t *testing.T) { for _, scope := range [][]string{nil, {"models", "retrieve"}} { diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 04872a1c..85b11746 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -361,11 +361,29 @@ func rebuildColonSeparatedArgs(root *cli.Command, args []string) []string { } result := []string{} + cmd := root + lineage := []*cli.Command{root} + flags := completionFlags(lineage) i := 0 for i < len(args) { current := args[i] + // A value-taking flag owns the next shell word. Do not let a trailing colon + // in that value absorb the command that follows it. + if isFlag(current) { + result = append(result, current) + if flag := findFlag(flags, current); flag != nil { + if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() && i+1 < len(args) { + result = append(result, args[i+1]) + i += 2 + continue + } + } + i++ + continue + } + for i+1 < len(args) { next := args[i+1] if next == ":" { @@ -386,6 +404,11 @@ func rebuildColonSeparatedArgs(root *cli.Command, args []string) []string { } result = append(result, current) + if child := findChild(cmd, current); child != nil { + cmd = child + lineage = append(lineage, child) + flags = completionFlags(lineage) + } i++ } diff --git a/internal/autocomplete/colon_args_test.go b/internal/autocomplete/colon_args_test.go index ead7600c..09f2770f 100644 --- a/internal/autocomplete/colon_args_test.go +++ b/internal/autocomplete/colon_args_test.go @@ -10,10 +10,15 @@ import ( func TestRebuildColonSeparatedArgs(t *testing.T) { t.Parallel() - root := &cli.Command{Commands: []*cli.Command{ - {Name: "config:get"}, - {Name: "config:set"}, - }} + root := &cli.Command{ + Flags: []cli.Flag{&cli.StringFlag{Name: "header"}}, + Commands: []*cli.Command{ + {Name: "config:get"}, + {Name: "config:set"}, + {Name: "completions", Commands: []*cli.Command{{Name: "create"}}}, + {Name: "chat:completions", Commands: []*cli.Command{{Name: "create"}}}, + }, + } tests := map[string]struct { args []string @@ -43,6 +48,10 @@ func TestRebuildColonSeparatedArgs(t *testing.T) { args: []string{"Prefix:", "value"}, want: []string{"Prefix:", "value"}, }, + "flag value ending in colon before command": { + args: []string{"--header", "chat:", "completions", "create", "--mo"}, + want: []string{"--header", "chat:", "completions", "create", "--mo"}, + }, } for name, test := range tests { From 9e272f988df7139bb5a866273ca707e033ede5cd Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:18:42 +0100 Subject: [PATCH 5/5] fix(autocomplete): preserve Bash colon-split flag values Signed-off-by: Sylvester Kaczmarek Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- internal/autocomplete/autocomplete.go | 34 +++++++++++++++++++++++- internal/autocomplete/colon_args_test.go | 9 +++++++ internal/autocomplete/protocol_test.go | 33 ++++++++++++++++------- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 85b11746..0edae6e4 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -375,8 +375,24 @@ func rebuildColonSeparatedArgs(root *cli.Command, args []string) []string { result = append(result, current) if flag := findFlag(flags, current); flag != nil { if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() && i+1 < len(args) { - result = append(result, args[i+1]) + value := args[i+1] i += 2 + + // Bash includes ':' in COMP_WORDBREAKS, so a single flag value such as + // `X:completions` can arrive as `X`, `:`, `completions`. Rebuild the + // split value here, but stop at a colon when the following tokens form + // a valid command path. That preserves both `X:completions models ...` + // and a value ending in a colon, such as `chat: completions create ...`. + for i < len(args) && args[i] == ":" { + value += ":" + i++ + if i >= len(args) || commandTailStartsAt(cmd, args[i:]) { + break + } + value += args[i] + i++ + } + result = append(result, value) continue } } @@ -415,6 +431,22 @@ func rebuildColonSeparatedArgs(root *cli.Command, args []string) []string { return result } +func commandTailStartsAt(cmd *cli.Command, args []string) bool { + matched := false + for _, arg := range args { + if isFlag(arg) { + return matched + } + child := findChild(cmd, arg) + if child == nil { + return false + } + matched = true + cmd = child + } + return matched +} + func hasCommandPrefix(cmd *cli.Command, prefix string) bool { if cmd == nil { return false diff --git a/internal/autocomplete/colon_args_test.go b/internal/autocomplete/colon_args_test.go index 09f2770f..9a7da172 100644 --- a/internal/autocomplete/colon_args_test.go +++ b/internal/autocomplete/colon_args_test.go @@ -16,6 +16,7 @@ func TestRebuildColonSeparatedArgs(t *testing.T) { {Name: "config:get"}, {Name: "config:set"}, {Name: "completions", Commands: []*cli.Command{{Name: "create"}}}, + {Name: "models", Commands: []*cli.Command{{Name: "retrieve"}}}, {Name: "chat:completions", Commands: []*cli.Command{{Name: "create"}}}, }, } @@ -52,6 +53,14 @@ func TestRebuildColonSeparatedArgs(t *testing.T) { args: []string{"--header", "chat:", "completions", "create", "--mo"}, want: []string{"--header", "chat:", "completions", "create", "--mo"}, }, + "bash-split colon inside flag value": { + args: []string{"--header", "X", ":", "completions", "models", "retrieve", "--mo"}, + want: []string{"--header", "X:completions", "models", "retrieve", "--mo"}, + }, + "bash-split trailing colon before command": { + args: []string{"--header", "chat", ":", "completions", "create", "--mo"}, + want: []string{"--header", "chat:", "completions", "create", "--mo"}, + }, } for name, test := range tests { diff --git a/internal/autocomplete/protocol_test.go b/internal/autocomplete/protocol_test.go index 907f9351..24c98a2b 100644 --- a/internal/autocomplete/protocol_test.go +++ b/internal/autocomplete/protocol_test.go @@ -25,10 +25,12 @@ func TestShellCompletionProtocolHelper(t *testing.T) { Flags: []cli.Flag{ &cli.StringFlag{Name: "format"}, &cli.StringFlag{Name: "file", TakesFile: true}, + &cli.StringFlag{Name: "header"}, }, Commands: []*cli.Command{ {Name: "models", Commands: []*cli.Command{ {Name: "list", Flags: []cli.Flag{&cli.IntFlag{Name: "max-items"}}}, + {Name: "retrieve", Flags: []cli.Flag{&cli.StringFlag{Name: "model"}}}, }}, {Name: "__complete", Hidden: true, SkipFlagParsing: true, Action: ExecuteShellCompletion}, }, @@ -57,17 +59,26 @@ func TestShellCompletionProtocol(t *testing.T) { for _, test := range []struct { name string args []string + bashArgs []string code int output string candidates string }{ - {"root value", []string{"--format", "candidate-"}, 11, "", ""}, - {"nested local value", []string{"models", "list", "--max-items", "candidate-"}, 11, "", ""}, - {"file value", []string{"--file", "candidate-"}, 10, "", "candidate-fixture.txt\n"}, - {"spaced preceding value", []string{"--format", "two words", "--file", "candidate-"}, 10, "", "candidate-fixture.txt\n"}, - {"empty preceding value", []string{"--format", "", "--file", "candidate-"}, 10, "", "candidate-fixture.txt\n"}, - {"explicit file prefix", []string{"--format", "@candidate-"}, 11, "", "@candidate-fixture.txt\n"}, - {"command prefix", []string{"mo"}, 0, "models\n", "models\n"}, + {name: "root value", args: []string{"--format", "candidate-"}, code: 11}, + {name: "nested local value", args: []string{"models", "list", "--max-items", "candidate-"}, code: 11}, + {name: "file value", args: []string{"--file", "candidate-"}, code: 10, candidates: "candidate-fixture.txt\n"}, + {name: "spaced preceding value", args: []string{"--format", "two words", "--file", "candidate-"}, code: 10, candidates: "candidate-fixture.txt\n"}, + {name: "empty preceding value", args: []string{"--format", "", "--file", "candidate-"}, code: 10, candidates: "candidate-fixture.txt\n"}, + {name: "explicit file prefix", args: []string{"--format", "@candidate-"}, code: 11, candidates: "@candidate-fixture.txt\n"}, + {name: "command prefix", args: []string{"mo"}, code: 0, output: "models\n", candidates: "models\n"}, + { + name: "bash colon-split header value", + args: []string{"--header", "X:completions", "models", "retrieve", "--mo"}, + bashArgs: []string{"--header", "X", ":", "completions", "models", "retrieve", "--mo"}, + code: 0, + output: "--model\n", + candidates: "--model\n", + }, } { t.Run(test.name, func(t *testing.T) { t.Parallel() @@ -117,7 +128,11 @@ for candidate in "${COMPREPLY[@]}"; do printf '%s\n' "$candidate" done ` - args := append([]string{"-c", probe, "completion-probe", binary}, test.args...) + bashArgs := test.args + if test.bashArgs != nil { + bashArgs = test.bashArgs + } + args := append([]string{"-c", probe, "completion-probe", binary}, bashArgs...) command := exec.Command(bash, args...) command.Dir, command.Env = dir, env stdout.Reset() @@ -128,7 +143,7 @@ done require.Equal(t, test.candidates, stdout.String()) argv, err := os.ReadFile(filepath.Join(dir, "helper.argv")) require.NoError(t, err) - wantArgs := append([]string{"__complete", "--"}, test.args...) + wantArgs := append([]string{"__complete", "--"}, bashArgs...) require.Equal(t, strings.Join(wantArgs, "\x00")+"\x00", string(argv)) }) })