diff --git a/README.md b/README.md index db83879..a405ec1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,28 @@ Installs `banger` (CLI), `bangerd` (daemon, auto-starts on first CLI call), and `banger-vsock-agent` (companion, under `$PREFIX/lib/banger/`). +### Shell completion + +`banger` ships completion scripts for bash, zsh, fish, and +powershell. Tab-completion covers subcommands, flags, and live +resource names (VM, image, kernel, session) looked up from the +daemon. With the daemon down, resource completion silently +returns nothing — no file-completion fallback. + +```bash +# bash (system-wide) +banger completion bash | sudo tee /etc/bash_completion.d/banger + +# zsh (user-local; ~/.zfunc must be on fpath) +banger completion zsh > ~/.zfunc/_banger + +# fish +banger completion fish > ~/.config/fish/completions/banger.fish +``` + +`banger completion --help` shows the shell-specific loading +recipes. + ## `vm run` One command, four common shapes: diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 3e41337..02de1f9 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -181,7 +181,6 @@ func NewBangerCommand() *cobra.Command { SilenceErrors: true, RunE: helpNoArgs, } - root.CompletionOptions.DisableDefaultCmd = true root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newKernelCommand(), newVersionCommand(), newPSCommand(), newVMCommand()) return root } @@ -846,15 +845,17 @@ Three modes: cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch") cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch") cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits") + _ = cmd.RegisterFlagCompletionFunc("image", completeImageNames) return cmd } func newVMKillCommand() *cobra.Command { var signal string cmd := &cobra.Command{ - Use: "kill ...", - Short: "Send a signal to a VM process", - Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] ..."), + Use: "kill ...", + Short: "Send a signal to a VM process", + Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] ..."), + ValidArgsFunction: completeVMNames, RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err @@ -935,6 +936,7 @@ func newVMCreateCommand() *cobra.Command { cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting") + _ = cmd.RegisterFlagCompletionFunc("image", completeImageNames) return cmd } @@ -1015,9 +1017,10 @@ func selectVMListVMs(vms []model.VMRecord, showAll, latest bool) []model.VMRecor func newVMShowCommand() *cobra.Command { return &cobra.Command{ - Use: "show ", - Short: "Show VM details", - Args: exactArgsUsage(1, "usage: banger vm show "), + Use: "show ", + Short: "Show VM details", + Args: exactArgsUsage(1, "usage: banger vm show "), + ValidArgsFunction: completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1034,9 +1037,10 @@ func newVMShowCommand() *cobra.Command { func newVMActionCommand(use, short, method string) *cobra.Command { return &cobra.Command{ - Use: use + " ...", - Short: short, - Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s ...", use)), + Use: use + " ...", + Short: short, + Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s ...", use)), + ValidArgsFunction: completeVMNames, RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err @@ -1072,9 +1076,10 @@ func newVMSetCommand() *cobra.Command { noNat bool ) cmd := &cobra.Command{ - Use: "set ...", - Short: "Update stopped VM settings", - Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] ..."), + Use: "set ...", + Short: "Update stopped VM settings", + Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] ..."), + ValidArgsFunction: completeVMNames, RunE: func(cmd *cobra.Command, args []string) error { params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat) if err != nil { @@ -1115,9 +1120,10 @@ func newVMSetCommand() *cobra.Command { func newVMSSHCommand() *cobra.Command { return &cobra.Command{ - Use: "ssh [ssh args...]", - Short: "SSH into a running VM", - Args: minArgsUsage(1, "usage: banger vm ssh [ssh args...]"), + Use: "ssh [ssh args...]", + Short: "SSH into a running VM", + Args: minArgsUsage(1, "usage: banger vm ssh [ssh args...]"), + ValidArgsFunction: completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, cfg, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1159,10 +1165,11 @@ func newVMWorkspacePrepareCommand() *cobra.Command { var mode string var readOnly bool cmd := &cobra.Command{ - Use: "prepare [path]", - Short: "Copy a local repo into a running VM", - Long: "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.", - Args: minArgsUsage(1, "usage: banger vm workspace prepare [path]"), + Use: "prepare [path]", + Short: "Copy a local repo into a running VM", + Long: "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.", + Args: minArgsUsage(1, "usage: banger vm workspace prepare [path]"), + ValidArgsFunction: completeVMNameOnlyAtPos0, Example: strings.TrimSpace(` banger vm workspace prepare devbox banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly @@ -1213,10 +1220,11 @@ func newVMWorkspaceExportCommand() *cobra.Command { var outputPath string var baseCommit string cmd := &cobra.Command{ - Use: "export ", - Short: "Pull changes from a guest workspace back to the host as a patch", - Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.", - Args: exactArgsUsage(1, "usage: banger vm workspace export "), + Use: "export ", + Short: "Pull changes from a guest workspace back to the host as a patch", + Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.", + Args: exactArgsUsage(1, "usage: banger vm workspace export "), + ValidArgsFunction: completeVMNameOnlyAtPos0, Example: strings.TrimSpace(` banger vm workspace export devbox | git apply banger vm workspace export devbox --base-commit abc1234 | git apply @@ -1286,10 +1294,11 @@ func newVMSessionStartCommand() *cobra.Command { var tagPairs []string var requiredCommands []string cmd := &cobra.Command{ - Use: "start [args...]", - Short: "Start a managed guest command", - Long: "Start a daemon-managed guest command. The daemon verifies that the guest working directory exists and that the requested command is present in guest PATH before launch. Use --stdin-mode pipe when you need live attach.", - Args: minArgsUsage(2, "usage: banger vm session start [flags] -- [args...]"), + Use: "start [args...]", + Short: "Start a managed guest command", + Long: "Start a daemon-managed guest command. The daemon verifies that the guest working directory exists and that the requested command is present in guest PATH before launch. Use --stdin-mode pipe when you need live attach.", + Args: minArgsUsage(2, "usage: banger vm session start [flags] -- [args...]"), + ValidArgsFunction: completeVMNameOnlyAtPos0, Example: strings.TrimSpace(` banger vm session start devbox --name planner --cwd /root/repo --stdin-mode pipe --require-command git -- pi --mode rpc --no-session banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash' @@ -1341,9 +1350,10 @@ func newVMSessionStartCommand() *cobra.Command { func newVMSessionListCommand() *cobra.Command { return &cobra.Command{ - Use: "list ", - Short: "List managed guest commands for a VM", - Args: exactArgsUsage(1, "usage: banger vm session list "), + Use: "list ", + Short: "List managed guest commands for a VM", + Args: exactArgsUsage(1, "usage: banger vm session list "), + ValidArgsFunction: completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1360,9 +1370,10 @@ func newVMSessionListCommand() *cobra.Command { func newVMSessionShowCommand() *cobra.Command { return &cobra.Command{ - Use: "show ", - Short: "Show managed guest command details", - Args: exactArgsUsage(2, "usage: banger vm session show "), + Use: "show ", + Short: "Show managed guest command details", + Args: exactArgsUsage(2, "usage: banger vm session show "), + ValidArgsFunction: completeSessionNames, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1381,9 +1392,10 @@ func newVMSessionLogsCommand() *cobra.Command { var stream string var tailLines int cmd := &cobra.Command{ - Use: "logs ", - Short: "Show stdout or stderr for a guest session", - Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] "), + Use: "logs ", + Short: "Show stdout or stderr for a guest session", + Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] "), + ValidArgsFunction: completeSessionNames, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1404,9 +1416,10 @@ func newVMSessionLogsCommand() *cobra.Command { func newVMSessionStopCommand() *cobra.Command { return &cobra.Command{ - Use: "stop ", - Short: "Send SIGTERM to a guest session", - Args: exactArgsUsage(2, "usage: banger vm session stop "), + Use: "stop ", + Short: "Send SIGTERM to a guest session", + Args: exactArgsUsage(2, "usage: banger vm session stop "), + ValidArgsFunction: completeSessionNames, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1423,9 +1436,10 @@ func newVMSessionStopCommand() *cobra.Command { func newVMSessionKillCommand() *cobra.Command { return &cobra.Command{ - Use: "kill ", - Short: "Send SIGKILL to a guest session", - Args: exactArgsUsage(2, "usage: banger vm session kill "), + Use: "kill ", + Short: "Send SIGKILL to a guest session", + Args: exactArgsUsage(2, "usage: banger vm session kill "), + ValidArgsFunction: completeSessionNames, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1442,10 +1456,11 @@ func newVMSessionKillCommand() *cobra.Command { func newVMSessionAttachCommand() *cobra.Command { return &cobra.Command{ - Use: "attach ", - Short: "Attach local stdio to an attachable guest session", - Long: "Attach local stdio to a pipe-mode session through a daemon-created local Unix socket bridge. Only one active attach is allowed at a time, and the client must run on the same host as the daemon.", - Args: exactArgsUsage(2, "usage: banger vm session attach "), + Use: "attach ", + Short: "Attach local stdio to an attachable guest session", + Long: "Attach local stdio to a pipe-mode session through a daemon-created local Unix socket bridge. Only one active attach is allowed at a time, and the client must run on the same host as the daemon.", + Args: exactArgsUsage(2, "usage: banger vm session attach "), + ValidArgsFunction: completeSessionNames, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1467,10 +1482,11 @@ func newVMSessionAttachCommand() *cobra.Command { func newVMSessionSendCommand() *cobra.Command { var message string cmd := &cobra.Command{ - Use: "send ", - Short: "Write bytes to a running guest session's stdin pipe", - Long: "Write a payload to the stdin pipe of a running pipe-mode guest session without holding the exclusive attach. Use --message for an inline JSONL string, or pipe bytes via stdin when --message is omitted. A trailing newline is appended to --message values that lack one.", - Args: exactArgsUsage(2, "usage: banger vm session send [--message '']"), + Use: "send ", + Short: "Write bytes to a running guest session's stdin pipe", + Long: "Write a payload to the stdin pipe of a running pipe-mode guest session without holding the exclusive attach. Use --message for an inline JSONL string, or pipe bytes via stdin when --message is omitted. A trailing newline is appended to --message values that lack one.", + Args: exactArgsUsage(2, "usage: banger vm session send [--message '']"), + ValidArgsFunction: completeSessionNames, Example: strings.TrimSpace(` banger vm session send devbox planner --message '{"type":"abort"}' banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}' @@ -1628,9 +1644,10 @@ func streamGuestSessionAttachInput(conn net.Conn, stdin io.Reader) error { func newVMLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ - Use: "logs ", - Short: "Show VM logs", - Args: exactArgsUsage(1, "usage: banger vm logs [-f] "), + Use: "logs ", + Short: "Show VM logs", + Args: exactArgsUsage(1, "usage: banger vm logs [-f] "), + ValidArgsFunction: completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1652,9 +1669,10 @@ func newVMLogsCommand() *cobra.Command { func newVMStatsCommand() *cobra.Command { return &cobra.Command{ - Use: "stats ", - Short: "Show VM stats", - Args: exactArgsUsage(1, "usage: banger vm stats "), + Use: "stats ", + Short: "Show VM stats", + Args: exactArgsUsage(1, "usage: banger vm stats "), + ValidArgsFunction: completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1671,9 +1689,10 @@ func newVMStatsCommand() *cobra.Command { func newVMPortsCommand() *cobra.Command { return &cobra.Command{ - Use: "ports ", - Short: "Show host-reachable listening guest ports", - Args: exactArgsUsage(1, "usage: banger vm ports "), + Use: "ports ", + Short: "Show host-reachable listening guest ports", + Args: exactArgsUsage(1, "usage: banger vm ports "), + ValidArgsFunction: completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1740,6 +1759,7 @@ func newImageRegisterCommand() *cobra.Command { cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().StringVar(¶ms.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')") cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared") + _ = cmd.RegisterFlagCompletionFunc("kernel-ref", completeKernelNames) return cmd } @@ -1813,14 +1833,16 @@ subcommand lands). cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().StringVar(¶ms.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')") cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4GiB); defaults to content + 25%, min 1GiB") + _ = cmd.RegisterFlagCompletionFunc("kernel-ref", completeKernelNames) return cmd } func newImagePromoteCommand() *cobra.Command { return &cobra.Command{ - Use: "promote ", - Short: "Promote an unmanaged image to a managed artifact", - Args: exactArgsUsage(1, "usage: banger image promote "), + Use: "promote ", + Short: "Promote an unmanaged image to a managed artifact", + Args: exactArgsUsage(1, "usage: banger image promote "), + ValidArgsFunction: completeImageNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err @@ -1859,9 +1881,10 @@ func newImageListCommand() *cobra.Command { func newImageShowCommand() *cobra.Command { return &cobra.Command{ - Use: "show ", - Short: "Show image details", - Args: exactArgsUsage(1, "usage: banger image show "), + Use: "show ", + Short: "Show image details", + Args: exactArgsUsage(1, "usage: banger image show "), + ValidArgsFunction: completeImageNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -1878,9 +1901,10 @@ func newImageShowCommand() *cobra.Command { func newImageDeleteCommand() *cobra.Command { return &cobra.Command{ - Use: "delete ", - Short: "Delete an image", - Args: exactArgsUsage(1, "usage: banger image delete "), + Use: "delete ", + Short: "Delete an image", + Args: exactArgsUsage(1, "usage: banger image delete "), + ValidArgsFunction: completeImageNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err @@ -2006,9 +2030,10 @@ func newKernelListCommand() *cobra.Command { func newKernelShowCommand() *cobra.Command { return &cobra.Command{ - Use: "show ", - Short: "Show kernel catalog entry details", - Args: exactArgsUsage(1, "usage: banger kernel show "), + Use: "show ", + Short: "Show kernel catalog entry details", + Args: exactArgsUsage(1, "usage: banger kernel show "), + ValidArgsFunction: completeKernelNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { @@ -2025,10 +2050,11 @@ func newKernelShowCommand() *cobra.Command { func newKernelRmCommand() *cobra.Command { return &cobra.Command{ - Use: "rm ", - Aliases: []string{"remove", "delete"}, - Short: "Remove a kernel catalog entry", - Args: exactArgsUsage(1, "usage: banger kernel rm "), + Use: "rm ", + Aliases: []string{"remove", "delete"}, + Short: "Remove a kernel catalog entry", + Args: exactArgsUsage(1, "usage: banger kernel rm "), + ValidArgsFunction: completeKernelNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..871ce85 --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,202 @@ +package cli + +import ( + "context" + + "banger/internal/api" + "banger/internal/paths" + "banger/internal/rpc" + + "github.com/spf13/cobra" +) + +// Completion helpers. Design notes: +// +// - Never auto-start the daemon. If it isn't running, return no +// suggestions + NoFileComp so the shell doesn't fall back to file +// completion (there are no local files that would plausibly match a +// VM or image name). +// - Filter out names already in args — avoids suggesting the same VM +// twice on variadic commands like `vm stop a b `. +// - Fail silently. Completion is advisory; any error path returns an +// empty suggestion list rather than propagating to the user. + +// completionListerFunc is the seam used by tests to avoid touching a +// real daemon socket. +var completionListerFunc = func(ctx context.Context, socketPath, method string) ([]string, error) { + switch method { + case "vm.list": + result, err := rpc.Call[api.VMListResult](ctx, socketPath, method, api.Empty{}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(result.VMs)) + for _, vm := range result.VMs { + if vm.Name != "" { + names = append(names, vm.Name) + } + } + return names, nil + case "image.list": + result, err := rpc.Call[api.ImageListResult](ctx, socketPath, method, api.Empty{}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(result.Images)) + for _, image := range result.Images { + if image.Name != "" { + names = append(names, image.Name) + } + } + return names, nil + case "kernel.list": + result, err := rpc.Call[api.KernelListResult](ctx, socketPath, method, api.Empty{}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(result.Entries)) + for _, entry := range result.Entries { + if entry.Name != "" { + names = append(names, entry.Name) + } + } + return names, nil + } + return nil, nil +} + +// completionSessionListerFunc is the seam for guest-session name lookups +// scoped to a VM. +var completionSessionListerFunc = func(ctx context.Context, socketPath, vmIDOrName string) ([]string, error) { + result, err := rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: vmIDOrName}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(result.Sessions)) + for _, session := range result.Sessions { + if session.Name != "" { + names = append(names, session.Name) + } + } + return names, nil +} + +// daemonSocketForCompletion returns the socket path IFF the daemon is +// already running. Returns "", false when no daemon is up — completion +// callers use this as the bail signal. +func daemonSocketForCompletion(ctx context.Context) (string, bool) { + layout, err := paths.Resolve() + if err != nil { + return "", false + } + if _, err := daemonPingFunc(ctx, layout.SocketPath); err != nil { + return "", false + } + return layout.SocketPath, true +} + +// filterPrefix returns the subset of candidates starting with toComplete +// that aren't in exclude. Comparison is case-sensitive because VM/image +// names preserve case. +func filterPrefix(candidates, exclude []string, toComplete string) []string { + excludeSet := make(map[string]struct{}, len(exclude)) + for _, e := range exclude { + excludeSet[e] = struct{}{} + } + out := make([]string, 0, len(candidates)) + for _, c := range candidates { + if _, skip := excludeSet[c]; skip { + continue + } + if toComplete == "" || hasPrefix(c, toComplete) { + out = append(out, c) + } + } + return out +} + +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} + +func completeVMNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + socket, ok := daemonSocketForCompletion(cmd.Context()) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names, err := completionListerFunc(cmd.Context(), socket, "vm.list") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeVMNameOnlyAtPos0 restricts VM-name completion to the first +// positional argument. Used by commands like `vm ssh [ssh args...]` +// where args after pos 0 are free-form. +func completeVMNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completeVMNames(cmd, args, toComplete) +} + +func completeImageNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completeImageNames(cmd, args, toComplete) +} + +func completeKernelNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completeKernelNames(cmd, args, toComplete) +} + +func completeImageNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + socket, ok := daemonSocketForCompletion(cmd.Context()) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names, err := completionListerFunc(cmd.Context(), socket, "image.list") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +func completeKernelNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + socket, ok := daemonSocketForCompletion(cmd.Context()) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names, err := completionListerFunc(cmd.Context(), socket, "kernel.list") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// completeSessionNames handles `... ` commands: pos 0 +// completes VMs, pos 1 completes sessions owned by args[0], pos 2+ is +// silent. +func completeSessionNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return completeVMNames(cmd, args, toComplete) + case 1: + socket, ok := daemonSocketForCompletion(cmd.Context()) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names, err := completionSessionListerFunc(cmd.Context(), socket, args[0]) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return filterPrefix(names, nil, toComplete), cobra.ShellCompDirectiveNoFileComp + default: + return nil, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..6ef2dee --- /dev/null +++ b/internal/cli/completion_test.go @@ -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) + } +}