banger/internal/cli/completion.go
Thales Maciel 003b0488ce
cli,docs: trivial polish for v0.1.0
A pre-release audit collected ~12 trivial-effort UX and code-hygiene
items. Rolling them up here so the v0.1.0 commit log isn't littered
with one-line tweaks.

CLI help / completion:
  * commands_image.go: drop dangling reference to a `banger image
    catalog` subcommand that doesn't exist; replace with a pointer
    to `banger image list`.
  * commands_image.go: --size flag example was "4GiB" but the parser
    rejects that suffix. Change example to "4G". (Parser-side fix
    is in a separate concern.)
  * commands_image.go + completion.go: image pull now wires a
    catalog completer (falls back to local image names since there's
    no image-catalog RPC yet); image show / delete / promote already
    completed local names.
  * commands_kernel.go + completion.go: kernel pull now wires a new
    completeKernelCatalogNameOnlyAtPos0 backed by the kernel.catalog
    RPC, so tab-complete suggests pullable kernels.
  * commands_vm.go: vm stats and vm set now have Long + Example
    blocks (peers all do); --from flag description updated to spell
    out the relationship to --branch.

README:
  * Define "golden image" inline at first use.
  * Add a one-line Requirements block above Quick Start so users
    hit the firecracker / KVM dependency before `make build`.

Code hygiene:
  * dashIfEmpty / emptyDash were the same function. Deleted
    emptyDash, retargeted three call sites.
  * formatBytes (introduced today in image cache prune) duplicated
    humanSize. Consolidated to humanSize, now with a space ("1.2
    GiB" not "1.2GiB"). formatters_test.go expectations updated.

Logging chattiness:
  * "operation started" (logger.go), "daemon request canceled"
    (daemon.go), and "helper rpc completed" (roothelper.go) all
    fired at INFO per RPC. Downgraded to DEBUG so routine shell
    completions don't spam syslog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:31:54 -03:00

191 lines
6.5 KiB
Go

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.
// defaultCompletionLister backs the *deps.completionLister field;
// tests inject their own fake via the struct instead of mutating
// package-level vars.
func defaultCompletionLister(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
}
// 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 (d *deps) daemonSocketForCompletion(ctx context.Context) (string, bool) {
layout := paths.ResolveSystem()
if _, err := d.daemonPing(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 (d *deps) completeVMNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
socket, ok := d.daemonSocketForCompletion(cmd.Context())
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names, err := d.completionLister(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 (d *deps) completeVMNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return d.completeVMNames(cmd, args, toComplete)
}
func (d *deps) completeImageNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return d.completeImageNames(cmd, args, toComplete)
}
func (d *deps) completeKernelNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return d.completeKernelNames(cmd, args, toComplete)
}
func (d *deps) completeImageNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
socket, ok := d.daemonSocketForCompletion(cmd.Context())
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names, err := d.completionLister(cmd.Context(), socket, "image.list")
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
}
func (d *deps) completeKernelNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
socket, ok := d.daemonSocketForCompletion(cmd.Context())
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names, err := d.completionLister(cmd.Context(), socket, "kernel.list")
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeKernelCatalogNameOnlyAtPos0 completes kernel names from the
// remote catalog (pulled + available) at position 0 only.
func (d *deps) completeKernelCatalogNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
socket, ok := d.daemonSocketForCompletion(cmd.Context())
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
result, err := rpc.Call[api.KernelCatalogResult](cmd.Context(), socket, "kernel.catalog", api.Empty{})
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names := make([]string, 0, len(result.Entries))
for _, entry := range result.Entries {
if entry.Name != "" {
names = append(names, entry.Name)
}
}
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeImageCatalogNameOnlyAtPos0 falls back to the locally-installed
// image list (there is no remote image catalog RPC today).
func (d *deps) completeImageCatalogNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return d.completeImageNameOnlyAtPos0(cmd, args, toComplete)
}