From ff7ea5fd06ea45a6cfc3ed50bf6897b8bcbcb9bf Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:35:43 -0700 Subject: [PATCH] Add shell completion command Closes #164 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +++++ cmd/dispatch/cli.go | 87 ++++++++++++++++++++++++++++++++++++++++ cmd/dispatch/cli_test.go | 61 ++++++++++++++++++++++++++++ cmd/dispatch/main.go | 1 + 4 files changed, 159 insertions(+) diff --git a/README.md b/README.md index 10bf6e9..5862533 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,16 @@ dispatch 7. Press `s` to cycle sort fields, `S` to flip direction 8. Press `,` to open settings — change theme, launch mode, model, and more +### Shell Completion + +Print completion scripts for supported shells: + +```sh +dispatch completion bash +dispatch completion zsh +dispatch completion powershell +``` + ### Key Bindings #### Navigation diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 6c033d4..8e6ca3d 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -50,6 +50,18 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, nil + case "completion": + if len(args) < 2 { + err := errors.New("completion requires a shell: bash, zsh, or powershell") + fmt.Fprintf(os.Stderr, "completion: %v\n", err) + return true, cleanup, err + } + if cErr := runCompletion(os.Stdout, args[1]); cErr != nil { + fmt.Fprintf(os.Stderr, "completion: %v\n", cErr) + return true, cleanup, cErr + } + return true, cleanup, nil + case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -99,6 +111,81 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd return false, cleanup, nil } +func runCompletion(w io.Writer, shell string) error { + if w == nil { + w = io.Discard + } + switch strings.ToLower(shell) { + case "bash": + fmt.Fprint(w, bashCompletionScript) + case "zsh": + fmt.Fprint(w, zshCompletionScript) + case "powershell", "pwsh": + fmt.Fprint(w, powershellCompletionScript) + default: + return fmt.Errorf("unsupported shell %q (want bash, zsh, or powershell)", shell) + } + return nil +} + +const bashCompletionScript = `# bash completion for dispatch +_dispatch_completion() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local commands="help version update completion" + local flags="-h --help -v --version --demo --clear-cache --reindex" + + if [[ "${COMP_CWORD}" -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${commands} ${flags}" -- "${cur}") ) + return 0 + fi + + if [[ "${COMP_WORDS[1]}" == "completion" ]]; then + COMPREPLY=( $(compgen -W "bash zsh powershell" -- "${cur}") ) + return 0 + fi +} +complete -F _dispatch_completion dispatch disp +` + +const zshCompletionScript = `#compdef dispatch disp +_dispatch_completion() { + local -a commands shells flags + commands=(help version update completion) + shells=(bash zsh powershell) + flags=(-h --help -v --version --demo --clear-cache --reindex) + + if (( CURRENT == 2 )); then + _describe -t commands 'dispatch command' commands || _describe -t flags 'dispatch flag' flags + return + fi + + if [[ ${words[2]} == completion ]]; then + _describe -t shells 'shell' shells + return + fi +} +_dispatch_completion "$@" +` + +const powershellCompletionScript = `# PowerShell completion for dispatch +$script:DispatchCommands = @('help', 'version', 'update', 'completion') +$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex') +$script:DispatchShells = @('bash', 'zsh', 'powershell') + +Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $tokens = @($commandAst.CommandElements | ForEach-Object { $_.ToString() }) + $values = if ($tokens.Count -ge 2 -and $tokens[1] -eq 'completion') { + $script:DispatchShells + } else { + $script:DispatchCommands + $script:DispatchFlags + } + $values | + Where-Object { $_ -like "$wordToComplete*" } | + ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } +} +` + // setupLogRedirect opens the log file (if configured via DISPATCH_LOG) and // redirects stderr to it. When no log file is configured, stderr is sent to // os.DevNull to keep Bubble Tea's alt-screen clean. Returns the writer for diff --git a/cmd/dispatch/cli_test.go b/cmd/dispatch/cli_test.go index af45ed1..682861d 100644 --- a/cmd/dispatch/cli_test.go +++ b/cmd/dispatch/cli_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "fmt" "io" @@ -83,6 +84,66 @@ func TestHandleArgs_VersionCommand(t *testing.T) { } } +func TestRunCompletion_SupportedShells(t *testing.T) { + for _, tc := range []struct { + shell string + want string + }{ + {"bash", "complete -F _dispatch_completion dispatch disp"}, + {"zsh", "#compdef dispatch disp"}, + {"powershell", "Register-ArgumentCompleter"}, + {"pwsh", "Register-ArgumentCompleter"}, + } { + t.Run(tc.shell, func(t *testing.T) { + var buf bytes.Buffer + if err := runCompletion(&buf, tc.shell); err != nil { + t.Fatalf("runCompletion: %v", err) + } + if !strings.Contains(buf.String(), tc.want) { + t.Errorf("completion output missing %q:\n%s", tc.want, buf.String()) + } + }) + } +} + +func TestRunCompletion_UnsupportedShell(t *testing.T) { + var buf bytes.Buffer + err := runCompletion(&buf, "fish") + if err == nil { + t.Fatal("expected error for unsupported shell") + } + if !strings.Contains(err.Error(), "unsupported shell") { + t.Errorf("error = %v", err) + } +} + +func TestHandleArgs_Completion(t *testing.T) { + ch := make(chan *update.UpdateInfo, 1) + + done, cleanup, err := handleArgs([]string{"completion", "bash"}, io.Discard, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true for completion") + } + if cleanup != nil { + t.Error("expected cleanup=nil for completion") + } +} + +func TestHandleArgs_CompletionMissingShell(t *testing.T) { + ch := make(chan *update.UpdateInfo, 1) + + done, _, err := handleArgs([]string{"completion"}, io.Discard, ch) + if err == nil { + t.Fatal("expected error for missing shell") + } + if !done { + t.Error("expected done=true for completion error") + } +} + func TestHandleArgs_UnknownFlag(t *testing.T) { ch := make(chan *update.UpdateInfo, 1) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e02623d..4bc7235 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -90,6 +90,7 @@ Usage: Commands: help Show this help message version Print the version + completion Print shell completion (bash, zsh, powershell) update Update dispatch to the latest release Flags: