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

@ -32,6 +32,28 @@ Installs `banger` (CLI), `bangerd` (daemon, auto-starts on first
CLI call), and `banger-vsock-agent` (companion, under CLI call), and `banger-vsock-agent` (companion, under
`$PREFIX/lib/banger/`). `$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` ## `vm run`
One command, four common shapes: One command, four common shapes:

View file

@ -181,7 +181,6 @@ func NewBangerCommand() *cobra.Command {
SilenceErrors: true, SilenceErrors: true,
RunE: helpNoArgs, RunE: helpNoArgs,
} }
root.CompletionOptions.DisableDefaultCmd = true
root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newKernelCommand(), newVersionCommand(), newPSCommand(), newVMCommand()) root.AddCommand(newDaemonCommand(), newDoctorCommand(), newImageCommand(), newInternalCommand(), newKernelCommand(), newVersionCommand(), newPSCommand(), newVMCommand())
return root return root
} }
@ -846,6 +845,7 @@ Three modes:
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch") 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().StringVar(&fromRef, "from", "HEAD", "base ref for --branch")
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits") cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
_ = cmd.RegisterFlagCompletionFunc("image", completeImageNames)
return cmd return cmd
} }
@ -855,6 +855,7 @@ func newVMKillCommand() *cobra.Command {
Use: "kill <id-or-name>...", Use: "kill <id-or-name>...",
Short: "Send a signal to a VM process", Short: "Send a signal to a VM process",
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."), Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
ValidArgsFunction: completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil { if err := system.EnsureSudo(cmd.Context()); err != nil {
return err 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().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size")
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting") cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
_ = cmd.RegisterFlagCompletionFunc("image", completeImageNames)
return cmd return cmd
} }
@ -1018,6 +1020,7 @@ func newVMShowCommand() *cobra.Command {
Use: "show <id-or-name>", Use: "show <id-or-name>",
Short: "Show VM details", Short: "Show VM details",
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1037,6 +1040,7 @@ func newVMActionCommand(use, short, method string) *cobra.Command {
Use: use + " <id-or-name>...", Use: use + " <id-or-name>...",
Short: short, Short: short,
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)), Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
ValidArgsFunction: completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil { if err := system.EnsureSudo(cmd.Context()); err != nil {
return err return err
@ -1075,6 +1079,7 @@ func newVMSetCommand() *cobra.Command {
Use: "set <id-or-name>...", Use: "set <id-or-name>...",
Short: "Update stopped VM settings", Short: "Update stopped VM settings",
Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."), Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
ValidArgsFunction: completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat) params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
if err != nil { if err != nil {
@ -1118,6 +1123,7 @@ func newVMSSHCommand() *cobra.Command {
Use: "ssh <id-or-name> [ssh args...]", Use: "ssh <id-or-name> [ssh args...]",
Short: "SSH into a running VM", Short: "SSH into a running VM",
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"), Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, cfg, err := ensureDaemon(cmd.Context()) layout, cfg, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1163,6 +1169,7 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
Short: "Copy a local repo into a running VM", 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.", 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 <id-or-name> [path]"), Args: minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
Example: strings.TrimSpace(` Example: strings.TrimSpace(`
banger vm workspace prepare devbox banger vm workspace prepare devbox
banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly
@ -1217,6 +1224,7 @@ func newVMWorkspaceExportCommand() *cobra.Command {
Short: "Pull changes from a guest workspace back to the host as a patch", 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.", 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 <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
Example: strings.TrimSpace(` Example: strings.TrimSpace(`
banger vm workspace export devbox | git apply banger vm workspace export devbox | git apply
banger vm workspace export devbox --base-commit abc1234 | git apply banger vm workspace export devbox --base-commit abc1234 | git apply
@ -1290,6 +1298,7 @@ func newVMSessionStartCommand() *cobra.Command {
Short: "Start a managed guest command", 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.", 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 <id-or-name> [flags] -- <command> [args...]"), Args: minArgsUsage(2, "usage: banger vm session start <id-or-name> [flags] -- <command> [args...]"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
Example: strings.TrimSpace(` 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 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' banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash'
@ -1344,6 +1353,7 @@ func newVMSessionListCommand() *cobra.Command {
Use: "list <id-or-name>", Use: "list <id-or-name>",
Short: "List managed guest commands for a VM", Short: "List managed guest commands for a VM",
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1363,6 +1373,7 @@ func newVMSessionShowCommand() *cobra.Command {
Use: "show <id-or-name> <session>", Use: "show <id-or-name> <session>",
Short: "Show managed guest command details", Short: "Show managed guest command details",
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"), Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
ValidArgsFunction: completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1384,6 +1395,7 @@ func newVMSessionLogsCommand() *cobra.Command {
Use: "logs <id-or-name> <session>", Use: "logs <id-or-name> <session>",
Short: "Show stdout or stderr for a guest session", Short: "Show stdout or stderr for a guest session",
Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"), Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"),
ValidArgsFunction: completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1407,6 +1419,7 @@ func newVMSessionStopCommand() *cobra.Command {
Use: "stop <id-or-name> <session>", Use: "stop <id-or-name> <session>",
Short: "Send SIGTERM to a guest session", Short: "Send SIGTERM to a guest session",
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"), Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
ValidArgsFunction: completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1426,6 +1439,7 @@ func newVMSessionKillCommand() *cobra.Command {
Use: "kill <id-or-name> <session>", Use: "kill <id-or-name> <session>",
Short: "Send SIGKILL to a guest session", Short: "Send SIGKILL to a guest session",
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"), Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
ValidArgsFunction: completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1446,6 +1460,7 @@ func newVMSessionAttachCommand() *cobra.Command {
Short: "Attach local stdio to an attachable guest session", 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.", 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 <id-or-name> <session>"), Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
ValidArgsFunction: completeSessionNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1471,6 +1486,7 @@ func newVMSessionSendCommand() *cobra.Command {
Short: "Write bytes to a running guest session's stdin pipe", 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.", 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 <id-or-name> <session> [--message '<json>']"), Args: exactArgsUsage(2, "usage: banger vm session send <id-or-name> <session> [--message '<json>']"),
ValidArgsFunction: completeSessionNames,
Example: strings.TrimSpace(` Example: strings.TrimSpace(`
banger vm session send devbox planner --message '{"type":"abort"}' banger vm session send devbox planner --message '{"type":"abort"}'
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}' banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
@ -1631,6 +1647,7 @@ func newVMLogsCommand() *cobra.Command {
Use: "logs <id-or-name>", Use: "logs <id-or-name>",
Short: "Show VM logs", Short: "Show VM logs",
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1655,6 +1672,7 @@ func newVMStatsCommand() *cobra.Command {
Use: "stats <id-or-name>", Use: "stats <id-or-name>",
Short: "Show VM stats", Short: "Show VM stats",
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1674,6 +1692,7 @@ func newVMPortsCommand() *cobra.Command {
Use: "ports <id-or-name>", Use: "ports <id-or-name>",
Short: "Show host-reachable listening guest ports", Short: "Show host-reachable listening guest ports",
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
ValidArgsFunction: completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1740,6 +1759,7 @@ func newImageRegisterCommand() *cobra.Command {
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir") cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().StringVar(&params.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')") cmd.Flags().StringVar(&params.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
cmd.Flags().BoolVar(&params.Docker, "docker", false, "mark image as docker-prepared") cmd.Flags().BoolVar(&params.Docker, "docker", false, "mark image as docker-prepared")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", completeKernelNames)
return cmd return cmd
} }
@ -1813,6 +1833,7 @@ subcommand lands).
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir") cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().StringVar(&params.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')") cmd.Flags().StringVar(&params.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.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4GiB); defaults to content + 25%, min 1GiB")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", completeKernelNames)
return cmd return cmd
} }
@ -1821,6 +1842,7 @@ func newImagePromoteCommand() *cobra.Command {
Use: "promote <id-or-name>", Use: "promote <id-or-name>",
Short: "Promote an unmanaged image to a managed artifact", Short: "Promote an unmanaged image to a managed artifact",
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"), Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
ValidArgsFunction: completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil { if err := system.EnsureSudo(cmd.Context()); err != nil {
return err return err
@ -1862,6 +1884,7 @@ func newImageShowCommand() *cobra.Command {
Use: "show <id-or-name>", Use: "show <id-or-name>",
Short: "Show image details", Short: "Show image details",
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"), Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
ValidArgsFunction: completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -1881,6 +1904,7 @@ func newImageDeleteCommand() *cobra.Command {
Use: "delete <id-or-name>", Use: "delete <id-or-name>",
Short: "Delete an image", Short: "Delete an image",
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"), Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
ValidArgsFunction: completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil { if err := system.EnsureSudo(cmd.Context()); err != nil {
return err return err
@ -2009,6 +2033,7 @@ func newKernelShowCommand() *cobra.Command {
Use: "show <name>", Use: "show <name>",
Short: "Show kernel catalog entry details", Short: "Show kernel catalog entry details",
Args: exactArgsUsage(1, "usage: banger kernel show <name>"), Args: exactArgsUsage(1, "usage: banger kernel show <name>"),
ValidArgsFunction: completeKernelNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {
@ -2029,6 +2054,7 @@ func newKernelRmCommand() *cobra.Command {
Aliases: []string{"remove", "delete"}, Aliases: []string{"remove", "delete"},
Short: "Remove a kernel catalog entry", Short: "Remove a kernel catalog entry",
Args: exactArgsUsage(1, "usage: banger kernel rm <name>"), Args: exactArgsUsage(1, "usage: banger kernel rm <name>"),
ValidArgsFunction: completeKernelNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context()) layout, _, err := ensureDaemon(cmd.Context())
if err != nil { if err != nil {

202
internal/cli/completion.go Normal file
View file

@ -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 <tab>`.
// - 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 <vm> [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 `... <vm> <session>` 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
}
}

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)
}
}