banger/internal/cli/commands_image.go
Thales Maciel 35bfac3f13
cli: rewrite help text for AI-driven discovery
Frontier models tend to discover a CLI by running --help, scanning
the Long description, and inferring the dominant workflow from the
examples. Today's banger help reads like a man page index — every
verb has a one-line Short and nothing else. This rewrites the
groups (banger, vm, vm workspace, image, kernel, system,
ssh-config) so each landing page answers "what is this for, what's
the 80% command, what comes next" in three to ten lines, with
runnable examples.

Also disambiguates the near-twin lifecycle commands so a model
reading the subcommand index can tell stop/kill/delete apart at a
glance:

  start    Start a stopped VM
  stop     Stop a running VM gracefully
  restart  Stop then start a VM
  kill     Force-kill a VM (use when 'vm stop' hangs)
  delete   Stop a VM and remove its disks (irreversible)

vm create / vm ssh / vm logs / vm show pick up Long descriptions
and examples for the same reason. No behaviour changes; help text
only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:02:08 -03:00

238 lines
8.6 KiB
Go

package cli
import (
"errors"
"fmt"
"strings"
"banger/internal/api"
"banger/internal/model"
"banger/internal/rpc"
"github.com/spf13/cobra"
)
func (d *deps) newImageCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "image",
Short: "Pull and manage banger images (rootfs + kernel + work-seed)",
Long: strings.TrimSpace(`
A banger image bundles a rootfs.ext4, a kernel, an optional initrd
+ modules, and an optional work-seed (the snapshot used to populate
each new VM's /root). Most users only need 'banger image pull
<catalog-name>' for the cataloged paths (see internal/imagecat),
or 'banger image pull <oci-ref>' for an OCI image.
Subcommands:
pull fetch a bundle by catalog name OR pull an OCI image
register point banger at an existing local rootfs (advanced)
promote copy a registered image's files into banger's managed dir
list show what's installed
show print one image's full record as JSON
delete remove an image (no VMs may reference it)
`),
Example: strings.TrimSpace(`
banger image pull debian-bookworm
banger image pull docker.io/library/alpine:3.20 --kernel-ref generic-6.12
banger image list
`),
RunE: helpNoArgs,
}
cmd.AddCommand(
d.newImageRegisterCommand(),
d.newImagePullCommand(),
d.newImagePromoteCommand(),
d.newImageListCommand(),
d.newImageShowCommand(),
d.newImageDeleteCommand(),
)
return cmd
}
func (d *deps) newImageRegisterCommand() *cobra.Command {
var params api.ImageRegisterParams
cmd := &cobra.Command{
Use: "register",
Short: "Register or update an unmanaged image",
Args: noArgsUsage("usage: banger image register --name <name> --rootfs <path> [--work-seed <path>] (--kernel <path> [--initrd <path>] [--modules <dir>] | --kernel-ref <name>)"),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(params.KernelRef) != "" && (params.KernelPath != "" || params.InitrdPath != "" || params.ModulesDir != "") {
return errors.New("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
}
if err := absolutizeImageRegisterPaths(&params); err != nil {
return err
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.register", params)
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.RootfsPath, "rootfs", "", "rootfs path")
cmd.Flags().StringVar(&params.WorkSeedPath, "work-seed", "", "work-seed path")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
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().BoolVar(&params.Docker, "docker", false, "mark image as docker-prepared")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
return cmd
}
func (d *deps) newImagePullCommand() *cobra.Command {
var (
params api.ImagePullParams
sizeRaw string
)
cmd := &cobra.Command{
Use: "pull <name-or-oci-ref>",
Short: "Pull an image bundle (catalog name) or OCI image and register it",
Long: strings.TrimSpace(`
Pull an image into banger. Two paths:
• Catalog name (e.g. 'debian-bookworm')
Fetches a pre-built bundle from the embedded imagecat catalog.
Kernel-ref comes from the catalog entry; --kernel-ref still
overrides.
• OCI reference (e.g. 'docker.io/library/debian:bookworm')
Pulls the image, flattens its layers, fixes ownership, injects
banger's guest agents. --kernel-ref or direct --kernel/--initrd/
--modules are required.
Use 'banger image catalog' to see available catalog names (once that
subcommand lands).
`),
Example: strings.TrimSpace(`
banger image pull debian-bookworm
banger image pull debian-bookworm --name sandbox
banger image pull docker.io/library/debian:bookworm --kernel-ref generic-6.12
`),
Args: exactArgsUsage(1, "usage: banger image pull <name-or-oci-ref> [--name <name>] [--kernel-ref <name>] [--kernel <path>] [--initrd <path>] [--modules <dir>] [--size <human>]"),
RunE: func(cmd *cobra.Command, args []string) error {
params.Ref = args[0]
if strings.TrimSpace(params.KernelRef) != "" && (params.KernelPath != "" || params.InitrdPath != "" || params.ModulesDir != "") {
return errors.New("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
}
if strings.TrimSpace(sizeRaw) != "" {
size, err := model.ParseSize(sizeRaw)
if err != nil {
return fmt.Errorf("--size: %w", err)
}
params.SizeBytes = size
}
if err := absolutizePaths(&params.KernelPath, &params.InitrdPath, &params.ModulesDir); err != nil {
return err
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
var result api.ImageShowResult
err = withHeartbeat(cmd.ErrOrStderr(), "image pull", func() error {
var callErr error
result, callErr = rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.pull", params)
return callErr
})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "image name (defaults to the ref's repo+tag, sanitised)")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
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(&sizeRaw, "size", "", "ext4 image size (e.g. 4GiB); defaults to content + 25%, min 1GiB")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
return cmd
}
func (d *deps) 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>"),
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.promote", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
}
func (d *deps) newImageListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List images",
Args: noArgsUsage("usage: banger image list"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{})
if err != nil {
return err
}
return printImageListTable(cmd.OutOrStdout(), result.Images)
},
}
}
func (d *deps) 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>"),
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.show", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Image)
},
}
}
func (d *deps) newImageDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <id-or-name>",
Aliases: []string{"rm"},
Short: "Delete an image",
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.delete", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
}