cli: shell completion via cobra + dynamic resource name lookups

Re-enable cobra's default `completion` subcommand (`banger completion
bash|zsh|fish|powershell`). Plus live resource-name suggestions that
hit the running daemon via the same RPC the real commands use:

  vm start/stop/restart/delete/kill/set         → completeVMNames (variadic)
  vm ssh/show/logs/stats/ports/...              → completeVMNameOnlyAtPos0
  vm session list/start                         → completeVMNameOnlyAtPos0
  vm session show/logs/stop/kill/attach/send    → completeSessionNames (vm + session)
  image show/delete/promote                     → completeImageNameOnlyAtPos0
  kernel show/rm                                → completeKernelNameOnlyAtPos0
  vm run/create --image, image pull/register --kernel-ref → flag-value completion

Design notes in internal/cli/completion.go: completers never auto-start
the daemon (ping-check, bail with NoFileComp on miss), so tab-completion
stays a zero-cost probe. Variadic completers exclude already-entered
args to avoid duplicate suggestions.

README: install recipes for bash / zsh / fish.
This commit is contained in:
Thales Maciel 2026-04-19 12:12:40 -03:00
parent 346eaba673
commit e3eaa0c797
No known key found for this signature in database
GPG key ID: 33112E6833C34679
4 changed files with 556 additions and 76 deletions

View file

@ -0,0 +1,230 @@
package cli
import (
"context"
"errors"
"reflect"
"testing"
"banger/internal/api"
"github.com/spf13/cobra"
)
// stubCompletionSeams installs test doubles for the daemon ping + lister
// seams and restores the originals on cleanup. Tests opt into the
// sub-functions they actually need.
func stubCompletionSeams(
t *testing.T,
pingErr error,
names map[string][]string,
listErr error,
sessions map[string][]string,
sessionErr error,
) {
t.Helper()
origPing := daemonPingFunc
origLister := completionListerFunc
origSessionLister := completionSessionListerFunc
t.Cleanup(func() {
daemonPingFunc = origPing
completionListerFunc = origLister
completionSessionListerFunc = origSessionLister
})
daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) {
if pingErr != nil {
return api.PingResult{}, pingErr
}
return api.PingResult{}, nil
}
completionListerFunc = func(ctx context.Context, socketPath, method string) ([]string, error) {
if listErr != nil {
return nil, listErr
}
return names[method], nil
}
completionSessionListerFunc = func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) {
if sessionErr != nil {
return nil, sessionErr
}
return sessions[vmIDOrName], nil
}
}
func TestFilterPrefix(t *testing.T) {
cases := []struct {
name string
candidates []string
exclude []string
prefix string
want []string
}{
{"no filter", []string{"a", "b"}, nil, "", []string{"a", "b"}},
{"prefix match", []string{"apple", "banana", "apricot"}, nil, "ap", []string{"apple", "apricot"}},
{"exclude already entered", []string{"a", "b", "c"}, []string{"b"}, "", []string{"a", "c"}},
{"prefix + exclude", []string{"alpha", "avocado", "banana"}, []string{"alpha"}, "a", []string{"avocado"}},
{"exact case sensitive", []string{"VM", "vm"}, nil, "v", []string{"vm"}},
{"empty candidates", nil, nil, "any", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := filterPrefix(tc.candidates, tc.exclude, tc.prefix)
if !reflect.DeepEqual(got, tc.want) {
// Allow nil == empty
if len(got) == 0 && len(tc.want) == 0 {
return
}
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func testCmdWithCtx() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.SetContext(context.Background())
return cmd
}
func TestCompleteVMNamesHappyPath(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
got, directive := completeVMNames(testCmdWithCtx(), nil, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %d, want NoFileComp", directive)
}
if !reflect.DeepEqual(got, []string{"alpha", "beta", "gamma"}) {
t.Errorf("got %v", got)
}
}
func TestCompleteVMNamesDaemonDown(t *testing.T) {
stubCompletionSeams(t, errors.New("connection refused"), nil, nil, nil, nil)
got, directive := completeVMNames(testCmdWithCtx(), nil, "")
if len(got) != 0 {
t.Errorf("daemon-down should return no suggestions, got %v", got)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %d, want NoFileComp", directive)
}
}
func TestCompleteVMNamesRPCError(t *testing.T) {
stubCompletionSeams(t, nil, nil, errors.New("rpc failed"), nil, nil)
got, _ := completeVMNames(testCmdWithCtx(), nil, "")
if len(got) != 0 {
t.Errorf("rpc error should return no suggestions, got %v", got)
}
}
func TestCompleteVMNamesExcludesAlreadyEntered(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "gamma"}}, nil, nil, nil)
got, _ := completeVMNames(testCmdWithCtx(), []string{"alpha"}, "")
want := []string{"beta", "gamma"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestCompleteVMNamesPrefixFilter(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha", "beta", "alphabet"}}, nil, nil, nil)
got, _ := completeVMNames(testCmdWithCtx(), nil, "alp")
want := []string{"alpha", "alphabet"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestCompleteVMNameOnlyAtPos0(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"vm.list": {"alpha"}}, nil, nil, nil)
atPos0, _ := completeVMNameOnlyAtPos0(testCmdWithCtx(), nil, "")
if len(atPos0) != 1 || atPos0[0] != "alpha" {
t.Errorf("pos 0: got %v", atPos0)
}
atPos1, _ := completeVMNameOnlyAtPos0(testCmdWithCtx(), []string{"alpha"}, "")
if len(atPos1) != 0 {
t.Errorf("pos 1+ should be silent, got %v", atPos1)
}
}
func TestCompleteImageNames(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"image.list": {"debian-bookworm", "alpine"}}, nil, nil, nil)
got, _ := completeImageNames(testCmdWithCtx(), nil, "")
if !reflect.DeepEqual(got, []string{"debian-bookworm", "alpine"}) {
t.Errorf("got %v", got)
}
}
func TestCompleteKernelNames(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"kernel.list": {"generic-6.12"}}, nil, nil, nil)
got, _ := completeKernelNames(testCmdWithCtx(), nil, "")
if len(got) != 1 || got[0] != "generic-6.12" {
t.Errorf("got %v", got)
}
}
func TestCompleteImageNameOnlyAtPos0SilentAfterFirst(t *testing.T) {
stubCompletionSeams(t, nil, map[string][]string{"image.list": {"alpine"}}, nil, nil, nil)
after, _ := completeImageNameOnlyAtPos0(testCmdWithCtx(), []string{"alpine"}, "")
if len(after) != 0 {
t.Errorf("expected silence at pos 1+, got %v", after)
}
}
func TestCompleteSessionNames(t *testing.T) {
stubCompletionSeams(
t,
nil,
map[string][]string{"vm.list": {"devbox"}},
nil,
map[string][]string{"devbox": {"planner", "worker"}},
nil,
)
// Position 0 → VMs.
vms, _ := completeSessionNames(testCmdWithCtx(), nil, "")
if len(vms) != 1 || vms[0] != "devbox" {
t.Errorf("pos 0: got %v", vms)
}
// Position 1 → sessions scoped to args[0].
sessions, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
if !reflect.DeepEqual(sessions, []string{"planner", "worker"}) {
t.Errorf("pos 1: got %v", sessions)
}
// Position 1 with prefix filter.
filtered, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "wor")
if len(filtered) != 1 || filtered[0] != "worker" {
t.Errorf("pos 1 prefix: got %v", filtered)
}
// Position 2+ silent.
past, _ := completeSessionNames(testCmdWithCtx(), []string{"devbox", "planner"}, "")
if len(past) != 0 {
t.Errorf("pos 2+: got %v", past)
}
}
func TestCompleteSessionNamesDaemonDown(t *testing.T) {
stubCompletionSeams(t, errors.New("down"), nil, nil, nil, nil)
got, directive := completeSessionNames(testCmdWithCtx(), []string{"devbox"}, "")
if len(got) != 0 {
t.Errorf("expected no suggestions when daemon down, got %v", got)
}
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %d, want NoFileComp", directive)
}
}