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
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue