Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ dispatch completion fish
dispatch completion powershell
```

Once the script is sourced, completion covers dynamic values too: `dispatch open <TAB>` completes your configured session aliases, `dispatch config get <TAB>` (and `set`/`unset`) completes config keys, and `dispatch completion <TAB>` 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.
Expand Down
77 changes: 59 additions & 18 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"

Expand All @@ -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)

Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions cmd/dispatch/complete.go
Original file line number Diff line number Diff line change
@@ -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 <kind>" 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
}
147 changes: 147 additions & 0 deletions cmd/dispatch/complete_test.go
Original file line number Diff line number Diff line change
@@ -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
}