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>
238 lines
8.6 KiB
Go
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(¶ms); 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(¶ms.Name, "name", "", "image name")
|
|
cmd.Flags().StringVar(¶ms.RootfsPath, "rootfs", "", "rootfs path")
|
|
cmd.Flags().StringVar(¶ms.WorkSeedPath, "work-seed", "", "work-seed path")
|
|
cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path")
|
|
cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path")
|
|
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", 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(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.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(¶ms.Name, "name", "", "image name (defaults to the ref's repo+tag, sanitised)")
|
|
cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path")
|
|
cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path")
|
|
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", 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)
|
|
},
|
|
}
|
|
}
|