diff --git a/README.md b/README.md index 6d2a8b7..0213e4e 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ dispatch completion fish dispatch completion powershell ``` +Once the script is sourced, completion covers dynamic values too: `dispatch open ` completes your configured session aliases, `dispatch config get ` (and `set`/`unset`) completes config keys, and `dispatch completion ` completes the supported shells. These come from your local config and static lists, so completion stays fast. + ### Diagnostics Run `dispatch doctor` to print setup checks for the config file, session store, session-state directory, and Copilot CLI binary. It also reports the detected Copilot CLI version and the number of stored sessions. diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index a540faf..f9f5bcc 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -177,6 +177,12 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "__complete": + // Hidden helper used by the shell completion scripts to fetch + // dynamic candidates. Deliberately omitted from help and usage. + runComplete(os.Stdout, args) + return true, cleanup, startupOptions{}, nil + case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -324,6 +330,7 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" + local bin="${COMP_WORDS[0]}" local commands="help version open new doctor update completion stats search tags config export" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" @@ -332,24 +339,33 @@ _dispatch_completion() { return 0 fi - if [[ "${COMP_WORDS[1]}" == "completion" ]]; then - COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "${cur}") ) - return 0 - fi - - if [[ "${COMP_WORDS[1]}" == "config" ]]; then - COMPREPLY=( $(compgen -W "list get set unset edit path" -- "${cur}") ) - return 0 - fi + case "${COMP_WORDS[1]}" in + completion) + COMPREPLY=( $(compgen -W "$("${bin}" __complete shells)" -- "${cur}") ) + return 0 + ;; + open) + COMPREPLY=( $(compgen -W "$("${bin}" __complete aliases)" -- "${cur}") ) + return 0 + ;; + config) + if [[ "${COMP_CWORD}" -eq 2 ]]; then + COMPREPLY=( $(compgen -W "list get set unset edit path" -- "${cur}") ) + elif [[ "${COMP_WORDS[2]}" == "get" || "${COMP_WORDS[2]}" == "set" || "${COMP_WORDS[2]}" == "unset" ]]; then + COMPREPLY=( $(compgen -W "$("${bin}" __complete config-keys)" -- "${cur}") ) + fi + return 0 + ;; + esac } complete -F _dispatch_completion dispatch disp ` const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { - local -a commands shells flags configsubs + local -a commands flags configsubs shells aliases configkeys + local bin=${words[1]} commands=(help version open new doctor update completion stats search tags config export) - shells=(bash zsh fish powershell) configsubs=(list get set unset edit path) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) @@ -359,12 +375,24 @@ _dispatch_completion() { fi if [[ ${words[2]} == completion ]]; then + shells=(${(f)"$($bin __complete shells)"}) _describe -t shells 'shell' shells return fi + if [[ ${words[2]} == open ]]; then + aliases=(${(f)"$($bin __complete aliases)"}) + _describe -t aliases 'session alias' aliases + return + fi + if [[ ${words[2]} == config ]]; then - _describe -t configsubs 'config subcommand' configsubs + if (( CURRENT == 3 )); then + _describe -t configsubs 'config subcommand' configsubs + elif [[ ${words[3]} == get || ${words[3]} == set || ${words[3]} == unset ]]; then + configkeys=(${(f)"$($bin __complete config-keys)"}) + _describe -t configkeys 'config key' configkeys + fi return fi } @@ -377,32 +405,45 @@ function __dispatch_needs_command test (count $cmd) -eq 1 end -function __dispatch_using_completion +function __dispatch_after + set -l cmd (commandline -opc) + test (count $cmd) -ge 2; and test $cmd[2] = $argv[1] +end + +function __dispatch_config_key set -l cmd (commandline -opc) - test (count $cmd) -ge 2; and test $cmd[2] = completion + test (count $cmd) -ge 3; and test $cmd[2] = config; and contains -- $cmd[3] get set unset end for bin in dispatch disp complete -c $bin -f complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export' complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query' - complete -c $bin -n '__dispatch_using_completion' -a 'bash zsh fish powershell' + complete -c $bin -n '__dispatch_after completion' -a "($bin __complete shells)" + complete -c $bin -n '__dispatch_after open' -a "($bin __complete aliases)" + complete -c $bin -n '__dispatch_config_key' -a "($bin __complete config-keys)" end ` const powershellCompletionScript = `# PowerShell completion for dispatch $script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') -$script:DispatchShells = @('bash', 'zsh', 'fish', 'powershell') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path') Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tokens = @($commandAst.CommandElements | ForEach-Object { $_.ToString() }) + $bin = $tokens[0] $values = if ($tokens.Count -ge 2 -and $tokens[1] -eq 'completion') { - $script:DispatchShells + & $bin __complete shells + } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'open') { + & $bin __complete aliases } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'config') { - $script:DispatchConfigSubcommands + if ($tokens.Count -ge 3 -and @('get', 'set', 'unset') -contains $tokens[2]) { + & $bin __complete config-keys + } else { + $script:DispatchConfigSubcommands + } } else { $script:DispatchCommands + $script:DispatchFlags } diff --git a/cmd/dispatch/complete.go b/cmd/dispatch/complete.go new file mode 100644 index 0000000..c6bc7a1 --- /dev/null +++ b/cmd/dispatch/complete.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "io" + "sort" +) + +// completionShells lists the shells the completion scripts support. It is the +// canonical source for the "shells" completion kind. +var completionShells = []string{"bash", "zsh", "fish", "powershell"} + +// runComplete backs the hidden "__complete " command. It prints +// newline-separated completion candidates for the requested kind so the shell +// completion scripts can offer dynamic values (session aliases, config keys) +// that cannot be hardcoded. Unknown kinds print nothing so a completer never +// breaks. The command is intentionally absent from help and usage output. +func runComplete(w io.Writer, args []string) { + if w == nil { + w = io.Discard + } + if len(args) < 2 { + return + } + + var candidates []string + switch args[1] { + case "aliases": + candidates = completeAliases() + case "config-keys": + candidates = completeConfigKeys() + case "shells": + candidates = completionShells + default: + return + } + + for _, c := range candidates { + fmt.Fprintln(w, c) + } +} + +// completeAliases returns the configured session aliases in sorted order. A +// config load failure yields no candidates rather than an error so completion +// stays non-fatal. +func completeAliases() []string { + cfg, err := configLoadFn() + if err != nil { + return nil + } + aliases := make([]string, 0, len(cfg.SessionAliases)) + for _, alias := range cfg.SessionAliases { + if alias != "" { + aliases = append(aliases, alias) + } + } + sort.Strings(aliases) + return aliases +} + +// completeConfigKeys returns the settable config key names in their stable +// display order. +func completeConfigKeys() []string { + fields := configFields() + keys := make([]string, 0, len(fields)) + for _, f := range fields { + keys = append(keys, f.name) + } + return keys +} diff --git a/cmd/dispatch/complete_test.go b/cmd/dispatch/complete_test.go new file mode 100644 index 0000000..9ab5608 --- /dev/null +++ b/cmd/dispatch/complete_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/update" +) + +func TestRunComplete_ConfigKeys(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "config-keys"}) + lines := splitNonEmptyLines(buf.String()) + + want := len(configFields()) + if len(lines) != want { + t.Fatalf("got %d keys, want %d:\n%s", len(lines), want, buf.String()) + } + for _, key := range []string{"agent", "model", "yoloMode", "theme"} { + if !containsLine(lines, key) { + t.Errorf("config-keys output missing %q", key) + } + } +} + +func TestRunComplete_Aliases(t *testing.T) { + cfg := config.Default() + cfg.SessionAliases = map[string]string{"s2": "bugfix", "s1": "authfix"} + withConfigSeams(t, cfg) + + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "aliases"}) + // Aliases are sorted regardless of map iteration order. + if got := buf.String(); got != "authfix\nbugfix\n" { + t.Errorf("aliases output = %q, want sorted list", got) + } +} + +func TestRunComplete_AliasesEmpty(t *testing.T) { + withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "aliases"}) + if got := buf.String(); got != "" { + t.Errorf("aliases output = %q, want empty", got) + } +} + +func TestRunComplete_AliasesLoadErrorIsSilent(t *testing.T) { + prev := configLoadFn + configLoadFn = func() (*config.Config, error) { return nil, errors.New("boom") } + t.Cleanup(func() { configLoadFn = prev }) + + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "aliases"}) + if got := buf.String(); got != "" { + t.Errorf("aliases output = %q, want empty on load error", got) + } +} + +func TestRunComplete_Shells(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "shells"}) + if got := buf.String(); got != "bash\nzsh\nfish\npowershell\n" { + t.Errorf("shells output = %q", got) + } +} + +func TestRunComplete_UnknownKindIsSilent(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "bogus"}) + if got := buf.String(); got != "" { + t.Errorf("unknown kind output = %q, want empty", got) + } +} + +func TestRunComplete_NoKindIsSilent(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete"}) + if got := buf.String(); got != "" { + t.Errorf("no-kind output = %q, want empty", got) + } +} + +func TestHandleArgs_Complete(t *testing.T) { + ch := make(chan *update.UpdateInfo, 1) + + done, cleanup, _, err := handleArgs([]string{"__complete", "shells"}, io.Discard, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true for __complete") + } + if cleanup != nil { + t.Error("expected cleanup=nil for __complete") + } +} + +func TestPrintUsage_HidesCompleteCommand(t *testing.T) { + var buf bytes.Buffer + prev := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + printUsage() + + w.Close() + os.Stdout = prev + <-done + + if strings.Contains(buf.String(), "__complete") { + t.Errorf("printUsage must not advertise the hidden __complete command:\n%s", buf.String()) + } +} + +func splitNonEmptyLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + out = append(out, line) + } + } + return out +} + +func containsLine(lines []string, want string) bool { + for _, l := range lines { + if l == want { + return true + } + } + return false +}