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:
parent
346eaba673
commit
e3eaa0c797
4 changed files with 556 additions and 76 deletions
22
README.md
22
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:
|
||||
|
|
|
|||
|
|
@ -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 <id-or-name>...",
|
||||
Short: "Send a signal to a VM process",
|
||||
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
||||
Use: "kill <id-or-name>...",
|
||||
Short: "Send a signal to a VM process",
|
||||
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
||||
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 <id-or-name>",
|
||||
Short: "Show VM details",
|
||||
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
|
||||
Use: "show <id-or-name>",
|
||||
Short: "Show VM details",
|
||||
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
|
||||
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 + " <id-or-name>...",
|
||||
Short: short,
|
||||
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
|
||||
Use: use + " <id-or-name>...",
|
||||
Short: short,
|
||||
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", 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 <id-or-name>...",
|
||||
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>..."),
|
||||
Use: "set <id-or-name>...",
|
||||
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>..."),
|
||||
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 <id-or-name> [ssh args...]",
|
||||
Short: "SSH into a running VM",
|
||||
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
|
||||
Use: "ssh <id-or-name> [ssh args...]",
|
||||
Short: "SSH into a running VM",
|
||||
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [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 <id-or-name> [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 <id-or-name> [path]"),
|
||||
Use: "prepare <id-or-name> [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 <id-or-name> [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 <id-or-name>",
|
||||
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 <id-or-name>"),
|
||||
Use: "export <id-or-name>",
|
||||
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 <id-or-name>"),
|
||||
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 <id-or-name> <command> [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 <id-or-name> [flags] -- <command> [args...]"),
|
||||
Use: "start <id-or-name> <command> [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 <id-or-name> [flags] -- <command> [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 <id-or-name>",
|
||||
Short: "List managed guest commands for a VM",
|
||||
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
||||
Use: "list <id-or-name>",
|
||||
Short: "List managed guest commands for a VM",
|
||||
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
|
||||
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 <id-or-name> <session>",
|
||||
Short: "Show managed guest command details",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
||||
Use: "show <id-or-name> <session>",
|
||||
Short: "Show managed guest command details",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
|
||||
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 <id-or-name> <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>"),
|
||||
Use: "logs <id-or-name> <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>"),
|
||||
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 <id-or-name> <session>",
|
||||
Short: "Send SIGTERM to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
||||
Use: "stop <id-or-name> <session>",
|
||||
Short: "Send SIGTERM to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
|
||||
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 <id-or-name> <session>",
|
||||
Short: "Send SIGKILL to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
||||
Use: "kill <id-or-name> <session>",
|
||||
Short: "Send SIGKILL to a guest session",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
|
||||
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 <id-or-name> <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.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
|
||||
Use: "attach <id-or-name> <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.",
|
||||
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
|
||||
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 <id-or-name> <session>",
|
||||
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 <id-or-name> <session> [--message '<json>']"),
|
||||
Use: "send <id-or-name> <session>",
|
||||
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 <id-or-name> <session> [--message '<json>']"),
|
||||
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 <id-or-name>",
|
||||
Short: "Show VM logs",
|
||||
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
|
||||
Use: "logs <id-or-name>",
|
||||
Short: "Show VM logs",
|
||||
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
|
||||
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 <id-or-name>",
|
||||
Short: "Show VM stats",
|
||||
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
|
||||
Use: "stats <id-or-name>",
|
||||
Short: "Show VM stats",
|
||||
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
|
||||
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 <id-or-name>",
|
||||
Short: "Show host-reachable listening guest ports",
|
||||
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
|
||||
Use: "ports <id-or-name>",
|
||||
Short: "Show host-reachable listening guest ports",
|
||||
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
|
||||
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 <id-or-name>",
|
||||
Short: "Promote an unmanaged image to a managed artifact",
|
||||
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
|
||||
Use: "promote <id-or-name>",
|
||||
Short: "Promote an unmanaged image to a managed artifact",
|
||||
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
|
||||
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 <id-or-name>",
|
||||
Short: "Show image details",
|
||||
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
|
||||
Use: "show <id-or-name>",
|
||||
Short: "Show image details",
|
||||
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
|
||||
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 <id-or-name>",
|
||||
Short: "Delete an image",
|
||||
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
|
||||
Use: "delete <id-or-name>",
|
||||
Short: "Delete an image",
|
||||
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
|
||||
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 <name>",
|
||||
Short: "Show kernel catalog entry details",
|
||||
Args: exactArgsUsage(1, "usage: banger kernel show <name>"),
|
||||
Use: "show <name>",
|
||||
Short: "Show kernel catalog entry details",
|
||||
Args: exactArgsUsage(1, "usage: banger kernel show <name>"),
|
||||
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 <name>",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove a kernel catalog entry",
|
||||
Args: exactArgsUsage(1, "usage: banger kernel rm <name>"),
|
||||
Use: "rm <name>",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Remove a kernel catalog entry",
|
||||
Args: exactArgsUsage(1, "usage: banger kernel rm <name>"),
|
||||
ValidArgsFunction: completeKernelNameOnlyAtPos0,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
layout, _, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
|
|
|
|||
202
internal/cli/completion.go
Normal file
202
internal/cli/completion.go
Normal 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
|
||||
}
|
||||
}
|
||||
230
internal/cli/completion_test.go
Normal file
230
internal/cli/completion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue